feat(sidebar): pin chats within projects#3913
Conversation
Persist pinned thread keys per logical project, keep pinned chats above the project's normal sort order, and separate pinned chats with a compact divider.
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Track row hover and focus directly instead of relying on nested group selectors, and hide row actions while a thread drag is active.
Render the pinned divider during active drags when all threads are in one group, allowing the first pin and last unpin gestures to cross a measurable boundary.
ApprovabilityVerdict: Needs human review 11 blocking correctness issues found. Diff is too large for automated approval analysis. A human reviewer should evaluate this PR. You can customize Macroscope's approvability policy. Learn more. |
Prevent drag activation from interactive controls, preserve mobile row metadata beside actions, and keep scoped active threads visible in collapsed previews.
Overlay the temporary divider when only pinned or unpinned threads exist so mounting it during a drag does not shift the active row.
Rename package scopes, CLI commands, app identifiers, storage paths, environment variables, native modules, assets, hosted endpoints, and documentation for the V12 fork. Carry forward the current orchestration, provider-stream, timeline, panel-motion, and runtime reliability improvements in the same buildable transition. BREAKING CHANGE: package names, the CLI command, application identifiers, environment variables, storage directories, deep links, and deployment endpoints now use V12 naming.
| ? path.join(homeDirectory, "Library", "Application Support") | ||
| : Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config")); | ||
| const baseDir = Option.getOrElse(config.t3Home, () => path.join(homeDirectory, ".t3")); | ||
| const baseDir = Option.getOrElse(config.v12Home, () => path.join(homeDirectory, ".v12")); |
There was a problem hiding this comment.
Home directory switch orphans state
High Severity
Default baseDir is now ~/.v12 with no fallback to ~/.t3, and the desktop bootstrap passes that as v12Home. Existing projects, SQLite, and settings under ~/.t3 are ignored after upgrade unless V12_HOME is set manually.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 91b9b66. Configure here.
| } | ||
| const cutoff = throughMessage.updatedAt; | ||
| const includedPlans = sourceThread.proposedPlans.filter((plan) => plan.createdAt <= cutoff); | ||
| const includedCheckpoints = sourceThread.checkpoints.filter( |
There was a problem hiding this comment.
🟡 Medium orchestration/decider.ts:311
In thread.fork, checkpoints are filtered by comparing checkpoint.completedAt to throughMessage.updatedAt. A checkpoint for the selected assistant turn is captured after the assistant message completes, so its completedAt is typically later than the message's updatedAt. This means the checkpoint whose turnId belongs to the retained message prefix is excluded, silently dropping the selected turn's diff/checkpoint history from the fork. Consider retaining checkpoints whose turnId matches a turn represented by the retained message prefix instead of using the timestamp cutoff.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/decider.ts around line 311:
In `thread.fork`, checkpoints are filtered by comparing `checkpoint.completedAt` to `throughMessage.updatedAt`. A checkpoint for the selected assistant turn is captured after the assistant message completes, so its `completedAt` is typically later than the message's `updatedAt`. This means the checkpoint whose `turnId` belongs to the retained message prefix is excluded, silently dropping the selected turn's diff/checkpoint history from the fork. Consider retaining checkpoints whose `turnId` matches a turn represented by the retained message prefix instead of using the timestamp cutoff.
| }); | ||
| if (revertResult._tag === "Failure") { | ||
| throw squashAtomCommandFailure(revertResult); | ||
| } |
There was a problem hiding this comment.
Stale tasks after edit-and-rerun
Medium Severity
onEditAndRerun copies Task HUD context tasks onto the recovery fork, then reverts the original thread, but never removes or prunes tasks on the source thread. After revert, pending tasks can still reference removed messages and get appended on the next send via pendingSend.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit bd2bd65. Configure here.
| setThreadError( | ||
| activeThread.id, | ||
| error instanceof Error ? error.message : "Failed to revert thread state.", | ||
| error instanceof Error ? error.message : "Failed to prepare this request for rerun.", |
There was a problem hiding this comment.
Orphan recovery on partial failure
Medium Severity
onEditAndRerun creates the recovery fork before waiting for projection, copying tasks, and reverting. If any later step fails, the catch path only surfaces an error and does not clean up the already-created recovery chat, so users can get an orphaned fork while the original thread is unchanged or only partly updated.
Reviewed by Cursor Bugbot for commit bd2bd65. Configure here.
| threadId: command.threadId, | ||
| proposedPlan, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Fork steals source proposed plans
High Severity
thread.fork copies proposed plans into the new thread without minting new plan IDs, while message and activity IDs are remapped. Projection upserts plans on global plan_id and rewrites thread_id on conflict, so applying the fork moves those plans off the source thread. Fork and Edit and rerun flows can silently strip plans from the original chat.
Reviewed by Cursor Bugbot for commit bd2bd65. Configure here.
| readonly status: OpenThreadActivityPresentation | null; | ||
| readonly variant?: "pill" | "toolbar"; | ||
| }) { | ||
| const [nowMs, setNowMs] = useState(() => Date.now()); |
There was a problem hiding this comment.
🟡 Medium components/OpenThreadActivityStatus.tsx:216
For a non-live status with endedAt: null (e.g. failed), the elapsed duration is computed against the stale mount-time nowMs instead of the current time, yielding a wrong or missing duration. The useEffect only refreshes nowMs while isLive is true, so any phase with a null endedAt that isn't classified as live never updates the timestamp used as the end time. Consider refreshing nowMs for any status whose endedAt is null, not just live phases.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/OpenThreadActivityStatus.tsx around line 216:
For a non-live status with `endedAt: null` (e.g. `failed`), the elapsed duration is computed against the stale mount-time `nowMs` instead of the current time, yielding a wrong or missing duration. The `useEffect` only refreshes `nowMs` while `isLive` is true, so any phase with a null `endedAt` that isn't classified as live never updates the timestamp used as the end time. Consider refreshing `nowMs` for any status whose `endedAt` is `null`, not just live phases.
| if ( | ||
| next.activityKind !== "tool.started" && | ||
| next.activityKind !== "tool.updated" && | ||
| next.activityKind !== "tool.completed" | ||
| ) { |
There was a problem hiding this comment.
🟡 Medium src/session-logic.ts:807
shouldCollapseToolLifecycleEntries now allows a tool.started row to collapse into a following tool.started row, so two consecutive identical tool starts without a toolCallId are merged into a single work-log entry. Concurrent or repeated identical tool calls silently lose one in-progress entry before either completion arrives. The next check should exclude tool.started so starts only collapse into a later tool.updated or tool.completed, not into another start.
if (
- next.activityKind !== "tool.started" &&
next.activityKind !== "tool.updated" &&
next.activityKind !== "tool.completed"
) {🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/session-logic.ts around lines 807-811:
`shouldCollapseToolLifecycleEntries` now allows a `tool.started` row to collapse into a following `tool.started` row, so two consecutive identical tool starts without a `toolCallId` are merged into a single work-log entry. Concurrent or repeated identical tool calls silently lose one in-progress entry before either completion arrives. The `next` check should exclude `tool.started` so starts only collapse into a later `tool.updated` or `tool.completed`, not into another start.
| endedAt: input.latestTurn?.completedAt ?? input.session.updatedAt, | ||
| }; | ||
| } | ||
| if (input.session?.status === "ready" || input.session?.status === "idle") { |
There was a problem hiding this comment.
🟡 Medium components/OpenThreadActivityStatus.tsx:90
When session.status is "idle" and there is no latestTurn (e.g., a freshly created session that has never dispatched a turn), deriveOpenThreadActivityPresentation returns a presentation with phase "complete" and label "Complete". This renders a persistent "Complete" status badge for a thread where no work has ever run. The "ready" || "idle" branch at line 90 returns unconditionally without checking for a settled turn. An idle session with no latestTurn should return null instead of synthesizing a completion.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/OpenThreadActivityStatus.tsx around line 90:
When `session.status` is `"idle"` and there is no `latestTurn` (e.g., a freshly created session that has never dispatched a turn), `deriveOpenThreadActivityPresentation` returns a presentation with phase `"complete"` and label `"Complete"`. This renders a persistent "Complete" status badge for a thread where no work has ever run. The `"ready" || "idle"` branch at line 90 returns unconditionally without checking for a settled turn. An `idle` session with no `latestTurn` should return `null` instead of synthesizing a completion.
| function latestUsefulActivity( | ||
| activities: ReadonlyArray<OrchestrationThreadActivity>, | ||
| turnId: string | null, | ||
| ): string | null { | ||
| let latest: OrchestrationThreadActivity | null = null; | ||
| for (const activity of activities) { | ||
| if (turnId !== null && activity.turnId !== null && activity.turnId !== turnId) { | ||
| continue; | ||
| } | ||
| if ( | ||
| latest === null || | ||
| activity.createdAt > latest.createdAt || | ||
| (activity.createdAt === latest.createdAt && | ||
| (activity.sequence ?? -1) > (latest.sequence ?? -1)) | ||
| ) { | ||
| latest = activity; | ||
| } | ||
| } | ||
| return latest?.summary ?? null; | ||
| } |
There was a problem hiding this comment.
🟡 Medium components/OpenThreadActivityStatus.tsx:43
latestUsefulActivity selects the newest activity by createdAt first, only using sequence as a tie-breaker. When provider events arrive with out-of-order timestamps (e.g. sequence 4 has an earlier createdAt than sequence 3), the function returns the sequence-3 summary instead of sequence 4, so the status HUD displays stale activity. Since sequence is the canonical ordering, it should be compared first, with createdAt only as a fallback tie-breaker.
| function latestUsefulActivity( | |
| activities: ReadonlyArray<OrchestrationThreadActivity>, | |
| turnId: string | null, | |
| ): string | null { | |
| let latest: OrchestrationThreadActivity | null = null; | |
| for (const activity of activities) { | |
| if (turnId !== null && activity.turnId !== null && activity.turnId !== turnId) { | |
| continue; | |
| } | |
| if ( | |
| latest === null || | |
| activity.createdAt > latest.createdAt || | |
| (activity.createdAt === latest.createdAt && | |
| (activity.sequence ?? -1) > (latest.sequence ?? -1)) | |
| ) { | |
| latest = activity; | |
| } | |
| } | |
| return latest?.summary ?? null; | |
| } | |
| function latestUsefulActivity( | |
| activities: ReadonlyArray<OrchestrationThreadActivity>, | |
| turnId: string | null, | |
| ): string | null { | |
| let latest: OrchestrationThreadActivity | null = null; | |
| for (const activity of activities) { | |
| if (turnId !== null && activity.turnId !== null && activity.turnId !== turnId) { | |
| continue; | |
| } | |
| if ( | |
| latest === null || | |
| (activity.sequence ?? -1) > (latest.sequence ?? -1) || | |
| (activity.sequence === latest.sequence && | |
| activity.createdAt > latest.createdAt) | |
| ) { | |
| latest = activity; | |
| } | |
| } | |
| return latest?.summary ?? null; | |
| } |
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/OpenThreadActivityStatus.tsx around lines 43-62:
`latestUsefulActivity` selects the newest activity by `createdAt` first, only using `sequence` as a tie-breaker. When provider events arrive with out-of-order timestamps (e.g. sequence 4 has an earlier `createdAt` than sequence 3), the function returns the sequence-3 summary instead of sequence 4, so the status HUD displays stale activity. Since `sequence` is the canonical ordering, it should be compared first, with `createdAt` only as a fallback tie-breaker.
| const selection = readSelectionWithin(container); | ||
| if (selection) { | ||
| setSelectedQuote(selection.quote); | ||
| setSelectionAnchorRect(selection.anchorRect); | ||
| } |
There was a problem hiding this comment.
🟡 Medium chat/MessagesTimeline.tsx:1073
When the user collapses the text selection (e.g., by clicking inside the message), captureSelection does not clear selectedQuote and selectionAnchorRect, so the old SelectionActionBar stays visible and can act on stale text and coordinates. The useEffect cleanup also schedules no debounced clear on unmount, compounding the staleness. In the null branch of captureSelection, call clearSelection so state resets when readSelectionWithin returns nothing.
const selection = readSelectionWithin(container);
- if (selection) {
- setSelectedQuote(selection.quote);
- setSelectionAnchorRect(selection.anchorRect);
- }
+ if (selection) {
+ setSelectedQuote(selection.quote);
+ setSelectionAnchorRect(selection.anchorRect);
+ } else {
+ clearSelection();
+ }🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/MessagesTimeline.tsx around lines 1073-1077:
When the user collapses the text selection (e.g., by clicking inside the message), `captureSelection` does not clear `selectedQuote` and `selectionAnchorRect`, so the old `SelectionActionBar` stays visible and can act on stale text and coordinates. The `useEffect` cleanup also schedules no debounced clear on unmount, compounding the staleness. In the `null` branch of `captureSelection`, call `clearSelection` so state resets when `readSelectionWithin` returns nothing.
| readonly nextRequest: string; | ||
| }): string { | ||
| const transcript = input.history | ||
| .map((message) => `${ROLE_LABELS[message.role]}:\n${message.text}`) |
There was a problem hiding this comment.
🟡 Medium orchestration/forkContinuation.ts:19
buildForkContinuationInput serializes each history message using only message.text, dropping message.attachments entirely. Forks created after image or file-based turns send the new provider session an incomplete transcript, so it cannot continue from attachments that are still visible in the fork's history. Consider including attachment content (or at least a placeholder reference) in the serialized transcript so multi-modal context survives the fork.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/forkContinuation.ts around line 19:
`buildForkContinuationInput` serializes each history message using only `message.text`, dropping `message.attachments` entirely. Forks created after image or file-based turns send the new provider session an incomplete transcript, so it cannot continue from attachments that are still visible in the fork's history. Consider including attachment content (or at least a placeholder reference) in the serialized transcript so multi-modal context survives the fork.
| } | ||
| } | ||
|
|
||
| function workEntryStatusLabel(workEntry: TimelineWorkEntry): string | null { |
There was a problem hiding this comment.
🟡 Medium chat/MessagesTimeline.tsx:2137
workEntryStatusLabel returns "Succeeded" for every entry with toolLifecycleStatus === "completed", but completed entries in this codebase can still represent tool failures (e.g., detail: "File not found..."). This causes SimpleWorkEntryRow to show a Succeeded label next to a failure indicator. Consider deriving the label from the existing failure/success helpers rather than lifecycle status alone.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/MessagesTimeline.tsx around line 2137:
`workEntryStatusLabel` returns `"Succeeded"` for every entry with `toolLifecycleStatus === "completed"`, but completed entries in this codebase can still represent tool failures (e.g., `detail: "File not found..."`). This causes `SimpleWorkEntryRow` to show a `Succeeded` label next to a failure indicator. Consider deriving the label from the existing failure/success helpers rather than lifecycle status alone.
| arch: arm64 | ||
| - label: macOS x64 | ||
| runner: blacksmith-12vcpu-macos-26 | ||
| runner: macos-15 |
There was a problem hiding this comment.
macOS runner OS version downgraded
Medium Severity
Both macOS matrix rows replace blacksmith-12vcpu-macos-26 with macos-15 instead of macos-26. Ubuntu and Windows keep the same OS version from their Blacksmith labels, so this looks like an accidental downgrade. Release packaging then runs on older Xcode/SDK tooling than the previous macOS 26 build environment.
Reviewed by Cursor Bugbot for commit c0eb2ba. Configure here.
|
Closing this accidental PR in favor of #4047. This PR's head tracked the fork's long-lived |
| if (currentRouteTarget?.kind === "draft") { | ||
| await materializeDraftThread(currentRouteTarget.draftId); | ||
| } |
There was a problem hiding this comment.
🟡 Medium hooks/useHandleNewThread.ts:62
When the current route is a content-bearing draft and the backend is unavailable, materializeDraftThread rejects and the rejection propagates out of useNewThreadHandler, so the rest of the callback never runs: no new local draft is created and no navigation occurs. Creating the destination draft and navigating to it are entirely local operations, so an environment-offline materialization failure should not block leaving the current draft. Consider wrapping the materializeDraftThread call in a try/catch (or .catch) so that local draft creation and navigation proceed regardless of whether the server-side thread could be created.
| if (currentRouteTarget?.kind === "draft") { | |
| await materializeDraftThread(currentRouteTarget.draftId); | |
| } | |
| if (currentRouteTarget?.kind === "draft") { | |
| try { | |
| await materializeDraftThread(currentRouteTarget.draftId); | |
| } catch { | |
| // Materialization is best-effort here; local draft creation and | |
| // navigation must still proceed when the backend is unavailable. | |
| } | |
| } |
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/hooks/useHandleNewThread.ts around lines 62-64:
When the current route is a content-bearing draft and the backend is unavailable, `materializeDraftThread` rejects and the rejection propagates out of `useNewThreadHandler`, so the rest of the callback never runs: no new local draft is created and no navigation occurs. Creating the destination draft and navigating to it are entirely local operations, so an environment-offline materialization failure should not block leaving the current draft. Consider wrapping the `materializeDraftThread` call in a try/catch (or `.catch`) so that local draft creation and navigation proceed regardless of whether the server-side thread could be created.
| if (result._tag === "Failure") { | ||
| throw squashAtomCommandFailure(result); | ||
| } | ||
| useComposerDraftStore.getState().markDraftThreadPromoting(draftId, threadRef); |
There was a problem hiding this comment.
🟠 High hooks/useMaterializeDraftThread.ts:79
When a populated draft is materialized, the user's composer content (prompt, images, terminal contexts, annotations, review comments) is permanently deleted instead of being migrated to the new server thread. createThread only persists thread metadata (title, model, modes), not the composer content. Then markDraftThreadPromoting triggers finalizePromotedDraftThread, which removes both the draft-session metadata and draftsByThreadKey[draftId] — discarding all unsent content the user had entered. Consider copying the composer draft state to the scoped server-thread key before removing the draft-session entry, so the content survives the promotion.
Also found in 1 other location(s)
apps/web/src/hooks/useHandleNewThread.ts:65
The handler always installs a fresh
draftIdwithsetLogicalProjectDraftThreadId, but only materializes the draft currently in the route. If project A already has an unpromoted content-bearing draft while the user is viewing a server thread (or a draft for another project), starting a new thread for A replaces A's logical-project mapping;setLogicalProjectDraftThreadIdthen deletes the prior unpromoted draft session and its composer state. The previous implementation explicitly found and reusedgetDraftSessionByLogicalProjectKey, so this change can silently discard an unfinished draft. The existing draft forlogicalProjectKeymust be preserved/reused or materialized before replacement.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/hooks/useMaterializeDraftThread.ts around line 79:
When a populated draft is materialized, the user's composer content (prompt, images, terminal contexts, annotations, review comments) is permanently deleted instead of being migrated to the new server thread. `createThread` only persists thread metadata (title, model, modes), not the composer content. Then `markDraftThreadPromoting` triggers `finalizePromotedDraftThread`, which removes both the draft-session metadata and `draftsByThreadKey[draftId]` — discarding all unsent content the user had entered. Consider copying the composer draft state to the scoped server-thread key before removing the draft-session entry, so the content survives the promotion.
Also found in 1 other location(s):
- apps/web/src/hooks/useHandleNewThread.ts:65 -- The handler always installs a fresh `draftId` with `setLogicalProjectDraftThreadId`, but only materializes the draft currently in the route. If project A already has an unpromoted content-bearing draft while the user is viewing a server thread (or a draft for another project), starting a new thread for A replaces A's logical-project mapping; `setLogicalProjectDraftThreadId` then deletes the prior unpromoted draft session and its composer state. The previous implementation explicitly found and reused `getDraftSessionByLogicalProjectKey`, so this change can silently discard an unfinished draft. The existing draft for `logicalProjectKey` must be preserved/reused or materialized before replacement.
| (draft.prompt.length > 0 || |
There was a problem hiding this comment.
🟡 Medium src/composerDraftStore.ts:689
hasComposerDraftUserContent checks draft.prompt.length > 0, so a prompt containing only whitespace is treated as user content. useMaterializeDraftThread trims the prompt before materialization, which means a draft with only spaces passes the user-content check and gets materialized as a server thread titled "Draft", and shows a "Draft" badge in the sidebar. Consider checking the trimmed prompt instead so whitespace-only drafts are treated as empty.
| (draft.prompt.length > 0 || | |
| draft.prompt.trim().length > 0 || |
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/composerDraftStore.ts around line 689:
`hasComposerDraftUserContent` checks `draft.prompt.length > 0`, so a prompt containing only whitespace is treated as user content. `useMaterializeDraftThread` trims the prompt before materialization, which means a draft with only spaces passes the user-content check and gets materialized as a server thread titled "Draft", and shows a "Draft" badge in the sidebar. Consider checking the trimmed prompt instead so whitespace-only drafts are treated as empty.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.
There are 9 total unresolved issues (including 5 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit dd9d094. Configure here.
| branch: draftSession.branch, | ||
| worktreePath: draftSession.worktreePath, | ||
| createdAt: draftSession.createdAt, | ||
| }, |
There was a problem hiding this comment.
Worktree mode lost after materialize
Medium Severity
useMaterializeDraftThread creates a server thread from a local draft, but only forwards branch and worktreePath. Draft envMode and startFromOrigin never reach the server thread. After navigate-away, ChatView treats the row as a server thread and drops draftThreadEnvMode, so a worktree-configured draft can send as local on return.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit dd9d094. Configure here.
| input: { | ||
| threadId: draftSession.threadId, | ||
| projectId: draftSession.projectId, | ||
| title: trimmedPrompt.length > 0 ? truncate(trimmedPrompt) : "Draft", |
There was a problem hiding this comment.
Weak title for non-text drafts
Low Severity
Materialization titles the thread from a trimmed prompt only, otherwise "Draft". hasComposerDraftUserContent also treats images and contexts as enough to materialize, so image- or context-only drafts become generically named sidebar threads instead of the richer titles used on first send.
Reviewed by Cursor Bugbot for commit dd9d094. Configure here.
| throw squashAtomCommandFailure(result); | ||
| } | ||
| useComposerDraftStore.getState().markDraftThreadPromoting(draftId, threadRef); | ||
| return true; |
There was a problem hiding this comment.
Send fails after draft materialize
Medium Severity
useMaterializeDraftThread creates an empty server thread and sets promotedTo, but the draft route only switches to server mode when threadHasStarted is true. Empty materialized shells never qualify, so returning to the draft route still renders as a local draft. Send then retries bootstrap.createThread for the same id, which the server rejects as a duplicate, so the message never sends.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit dd9d094. Configure here.
| const draftId = newDraftId(); | ||
| const threadId = newThreadId(); | ||
| const createdAt = new Date().toISOString(); | ||
| const initialEnvMode = options?.envMode ?? environmentSettings.defaultThreadEnvMode; | ||
| return (async () => { | ||
| if (currentRouteTarget?.kind === "draft") { | ||
| await materializeDraftThread(currentRouteTarget.draftId); | ||
| } |
There was a problem hiding this comment.
Silent New Chat materialize failure
Medium Severity
useNewThreadHandler awaits materializeDraftThread with no try/catch before creating the next draft. A failed create rejects the New Chat action with no toast, while ChatView's blocker path surfaces the same failure. Users can see New Chat do nothing when materialization fails.
Reviewed by Cursor Bugbot for commit dd9d094. Configure here.


What Changed
Why
Important chats should stay easy to reach without being pinned globally across unrelated projects. Project-scoped pinning keeps each sidebar group predictable while preserving the existing thread sort order for unpinned chats.
The hover control provides a quick direct action, while divider-crossing drag behavior makes bulk sidebar organization feel natural.
UI Changes
Interaction demo:
Screen.Recording.2026-07-12.at.11.51.13.PM.mp4
Checklist
Note
High Risk
Wide renames of env vars, home dirs, API well-known paths, CI package filters, and release/deploy config can break local dev, upgrades, and production pipelines if secrets/vars are not updated; persisted state under old T3 keys may be ignored.
Overview
This diff is a repo-wide rebrand and fork bootstrap, not the sidebar pinning work described in the PR title. Reviewers should treat the title/description as out of sync with the changes shown here unless additional commits exist outside this diff.
Product and package identity moves from T3 Code /
@t3toolsto V12 /@v12: root and apppackage.jsonnames, desktopproductName, npm CLI filters (v12,v12-relay,@v12/web), and Effect service tags (e.g.@v12/desktop/...). Imports and subpath exports shift to@v12/contracts,@v12/shared,@v12/client-runtime, etc.Runtime and storage paths rename
~/.t3/.t3to~/.v12/.v12, desktop user-data folder names (V12 (Dev)/V12 (Alpha)), custom URL schemesv12/v12-dev, and backend readiness/.well-known/v12/environment. Environment variables becomeV12_*(V12_HOME,V12_PORT,V12_CLERK_*,V12_RELAY_*, OTLP and web deploy vars)..env.exampleand dev-runner/desktop scripts follow the same naming (includingV12_DEV_REMOTE_V12_SERVER_ENTRY_PATHand dev-root flags like--v12-dev-root).CI and release workflows target
@v12/desktop,v12-relay, and V12-hosted defaults (e.g.app.v12.codes), release titles "V12 v$version", and mostly stock GitHub-hosted runners instead of Blacksmith labels..github/VOUCHED.tddrops inherited external trust entries for this fork.Contributor-facing docs replace the “not accepting contributions” stance with CONTRIBUTING.md, CODE_OF_CONDUCT.md, and SECURITY.md; README credits upstream T3 Code and points installs/releases at 12ya/v12. PR template adds validation checklist items (
vp check,vp run typecheck).Lint/docs/plans update oxlint plugin references (
@v12/oxlint-plugin-v12), macroscope notes, and planning docs from T3 → V12 (hosted app URLs, tunnel provider ids, service names).Migration risk: anyone with data under old keys/paths (
T3CODE_HOME,t3code:schemes,t3codeCommitHash, prior localStorage prefixes mentioned in Macroscope) will not see it unless a separate migration is added—this diff does not show one.Reviewed by Cursor Bugbot for commit dd9d094. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add chat pinning in project sidebar and introduce thread forking
uiStateStore.thread.forkcommand: the decider copies message history up to a selected message into a new thread, trackingparentThreadIdandforkedFromMessageIdthrough the schema, projection pipeline, and persistence layer (migration 033 adds the required columns).TaskHudoverlay replacing thePlanSidebar; it surfaces plan steps and contextual tasks, supports toggling, removing, and reordering steps, and appends pending tasks to outgoing prompts.ComposerPrimaryActionsso users can submit while a run is in progress.@t3tools/T3 Codeto@v12/V12, including package names, DI service keys, environment variables, storage keys, URL schemes, JWT claims, and API endpoints.@t3tools→@v12rename changes DI service identifiers, JWTtyp/iss/audclaim values, API paths (/.well-known/t3/environment→/v12/environment,/api/t3-connect/*→/api/v12-connect/*), local storage keys, IndexedDB names, and deep-link schemes (t3code://→v12://); existing persisted data under old keys will not be migrated automatically.Macroscope summarized dd9d094.