Skip to content

feat(composer): attach threads as context - #12296

Merged
juliusmarminge merged 0 commit into
t3code/codex-turn-mappingfrom
feat/thread-context-kind
Sep 17, 2026
Merged

juliusmarminge merged 0 commit into
t3code/codex-turn-mappingfrom
feat/thread-context-kind

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

Stacked on #2829 (t3code/codex-turn-mapping). Supersedes #12251, which built a parallel t3-thread:// link format instead of using context records.

Users want to point a new conversation at work that happened in another thread. This adds a thread composer context kind on the existing inline context system: a ThreadContextRecord in packages/contracts, one provider-projection case in packages/shared, and one chip registration per surface. Everything else (drafts, persistence, paste, copy, undo, legacy hosts, unknown-kind fallback) is inherited.

Two ways in. Type @ plus part of a title to pick a thread on web and mobile; thread matches lead the list and only appear for a typed query. On web and desktop, drag a thread out of the sidebar and drop it on the composer; a multi-selection drops together. The drag reuses the sidebar's pointer sensor, cancels the sort when the release lands outside the list, and moves the ghost through a tiny external store so pointer moves do not re-render the sidebar.

The prompt carries only the reference. The provider envelope gets the thread's identity plus a one-line instruction to read it with t3_thread_read, which now also accepts a thread the user attached even when it lives in another project. Only createdBy: "user" records grant that read, so an agent cannot widen its own reach, and writes stay project-scoped. Chips read the live title, so a renamed thread never shows a stale label, and open the thread on click (web Link, mobile navigation).

Also fixes a pre-existing bug found while verifying: the launchThread WebSocket handler dropped initialMessage.context for every context kind, so a new thread's first message lost all its context records. Follow-ups were unaffected.

Verified with focused tests (contracts round-trip, shared projection, client-runtime matcher, server MCP read/deny/no-write, web draft store and pointer sensor), scoped typecheck and lint for web, mobile, server, shared, client-runtime, and a live browser run: picker, drag, chip in draft and sent message, persistence across reload, and a real Codex turn calling t3_thread_read on the attached thread.

@ picker Chip in draft
thread-picker thread-chip-draft
Sidebar drag over composer Sent message, agent read the thread
thread-drag-over thread-sent-reply

Model: Claude Fable 5. Harness: Claude Code.

🤖 Generated with Claude Code


Devin Review

@github-actions github-actions Bot added size:XL 500-999 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. 📱 Native Change Changes the native fingerprint; merging blocks production OTAs until a new store build ships. labels Sep 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thread transfer impact

⚠️ The latest CI run did not produce a thread transfer result for 4c7ddb4.

This comment will update automatically after the next completed run.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High

composerFilesRef,
composerTerminalContextsRef,

getSendContext returns a stale threadContexts array after the user adds or removes a thread context, so the next dispatched message can omit the context currently attached to the composer. Because composerThreadContexts is missing from the useImperativeHandle dependency list, the imperative handle is not rebuilt when that state changes; include it in the dependencies.

       composerTerminalContextsRef,
+      composerThreadContexts,
       composerPreviewAnnotations,
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/chat/ChatComposer.tsx around lines 6338-6339:

`getSendContext` returns a stale `threadContexts` array after the user adds or removes a thread context, so the next dispatched message can omit the context currently attached to the composer. Because `composerThreadContexts` is missing from the `useImperativeHandle` dependency list, the imperative handle is not rebuilt when that state changes; include it in the dependencies.

imageCount: composerImages.length + composerFiles.length,
terminalContexts: composerTerminalContexts,
elementContextCount: composerPreviewAnnotations.length + composerReviewComments.length,
elementContextCount:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High components/ChatView.tsx:8082

A draft containing only an attached thread remains unsendable: ChatComposer sets hasSendableContent to false, so it disables the Send button and prevents submission. The handler passes composerThreadContexts to deriveComposerSendState here, but the composer's own calculation counts only preview annotations and review comments; include thread contexts in that calculation as well.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/ChatView.tsx around line 8082:

A draft containing only an attached thread remains unsendable: `ChatComposer` sets `hasSendableContent` to `false`, so it disables the Send button and prevents submission. The handler passes `composerThreadContexts` to `deriveComposerSendState` here, but the composer's own calculation counts only preview annotations and review comments; include thread contexts in that calculation as well.

Comment on lines +25 to +48
const shell = useThreadShell({ environmentId, threadId });
const title = shell?.title?.trim() || props.record.title;
return (
<Tooltip>
<TooltipTrigger
render={
<Link
to="/$environmentId/$threadId"
params={{ environmentId, threadId }}
aria-label={`Thread, ${title}`}
data-markdown-copy={props.copyMarkdown}
className={cn(
props.className,
CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES.thread,
CONTEXT_INLINE_CHIP_INTERACTIVE_CLASS_NAME,
"no-underline",
)}
>
<MessagesSquareIcon className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME} />
<span className={props.labelClassName}>{title}</span>
</Link>
}
/>
<TooltipPopup side="top">{shell ? "Open thread" : "Thread no longer available"}</TooltipPopup>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium components/ThreadContextChip.tsx:25

A deleted thread still renders as an active Link with the tooltip Open thread, so clicking the chip navigates to a thread whose shell is deleted and may redirect or show an empty chat surface. Because shell remains truthy after deletion, derive availability from !shell.deletedAt and block navigation for unavailable threads.

-  const shell = useThreadShell({ environmentId, threadId });
+  const shell = useThreadShell({ environmentId, threadId });
+  const isAvailable = Boolean(shell && !shell.deletedAt);
   const title = shell?.title?.trim() || props.record.title;
@@
             params={{ environmentId, threadId }}
             aria-label={`Thread, ${title}`}
+            aria-disabled={!isAvailable}
+            onClick={(event) => {
+              if (!isAvailable) event.preventDefault();
+            }}
             data-markdown-copy={props.copyMarkdown}
@@
-      <TooltipPopup side="top">{shell ? "Open thread" : "Thread no longer available"}</TooltipPopup>
+      <TooltipPopup side="top">{isAvailable ? "Open thread" : "Thread no longer available"}</TooltipPopup>
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/ThreadContextChip.tsx around lines 25-48:

A deleted thread still renders as an active `Link` with the tooltip `Open thread`, so clicking the chip navigates to a thread whose shell is deleted and may redirect or show an empty chat surface. Because `shell` remains truthy after deletion, derive availability from `!shell.deletedAt` and block navigation for unavailable threads.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

),
...[...composerImages, ...composerFiles]

buildContextClipboardFragment drops selected thread chips from the clipboard payload, so pasting a thread reference silently loses its attached-thread context (and returns null when it is the only selection). Include matching composerThreadContexts records in records so the thread importer can restore them.

         ),
+        ...composerThreadContexts.filter((context) => wanted.has(context.contextId)),
         ...[...composerImages, ...composerFiles]
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/chat/ChatComposer.tsx around lines 2963-2964:

`buildContextClipboardFragment` drops selected thread chips from the clipboard payload, so pasting a thread reference silently loses its attached-thread context (and returns `null` when it is the only selection). Include matching `composerThreadContexts` records in `records` so the thread importer can restore them.

@macroscopeapp

macroscopeapp Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This is a substantial cross-platform feature that adds persistent thread context, new composer workflows, provider instructions, and a broader MCP read-access path across project boundaries. Its production behavior and access-control implications, along with unresolved findings around sending, stale state, deleted threads, and clipboard restoration, warrant human review.

Not approved because:

  • 4 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

@juliusmarminge
juliusmarminge merged this pull request into t3code/codex-turn-mapping Sep 17, 2026
24 checks passed
@juliusmarminge
juliusmarminge deleted the feat/thread-context-kind branch September 17, 2026 19:36
return { draftsByThreadKey: nextDraftsByThreadKey };
});
},
addThreadContexts: (threadRef, records, options) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/composerDraftStore.ts:4030

moveComposerPromptAndImages preserves source.threadContexts but rebuilds the source prompt with only terminal references, so buildMessageContext still sends the thread context without a visible chip or reference. When an unsaved queued edit is recovered, the user therefore cannot see or remove the attached thread. Include the source thread-context references when rebuilding nextSource.prompt.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/composerDraftStore.ts around line 4030:

`moveComposerPromptAndImages` preserves `source.threadContexts` but rebuilds the source prompt with only terminal references, so `buildMessageContext` still sends the thread context without a visible chip or reference. When an unsaved queued edit is recovered, the user therefore cannot see or remove the attached thread. Include the source thread-context references when rebuilding `nextSource.prompt`.

);
if (!shell) return;
const record = threadComposerContext(item.thread, shell.title);
const existing = getComposerDraftSnapshot(ownerKey).context?.records ?? [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium threads/use-composer-command-menu.ts:514

Selecting a thread while editing a queued message inserts the reference into the queued draft but saves its context record under the thread-level ownerKey, so the queued message cannot retain or send that context and the record may be pruned as unreferenced. Use the draft key for the visible queued editor when reading and updating the composer draft context.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/threads/use-composer-command-menu.ts around line 514:

Selecting a thread while editing a queued message inserts the reference into the queued draft but saves its context record under the thread-level `ownerKey`, so the queued message cannot retain or send that context and the record may be pruned as unreferenced. Use the draft key for the visible queued editor when reading and updating the composer draft context.

juliusmarminge added a commit that referenced this pull request Sep 17, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 17, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 17, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 17, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 18, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 18, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 18, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 18, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 18, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 18, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 18, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 18, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 19, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 19, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 21, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 21, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 23, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 23, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 23, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 23, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 23, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 23, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 24, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 24, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 24, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
juliusmarminge added a commit that referenced this pull request Sep 25, 2026
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

📱 Native Change Changes the native fingerprint; merging blocks production OTAs until a new store build ships. size:XL 500-999 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant