Skip to content

feat(sidebar): pin chats within projects#3913

Closed
12ya wants to merge 24 commits into
pingdotgg:mainfrom
12ya:main
Closed

feat(sidebar): pin chats within projects#3913
12ya wants to merge 24 commits into
pingdotgg:mainfrom
12ya:main

Conversation

@12ya

@12ya 12ya commented Jul 12, 2026

Copy link
Copy Markdown

What Changed

  • Persist pinned chat state per logical project.
  • Keep pinned chats above the normal thread sort order for each project.
  • Add hover pin and unpin controls without consuming title space.
  • Support pinning and unpinning by dragging a chat across the pinned divider.
  • Show a compact divider with a centered pin icon and distinct pin/unpin feedback colors.
  • Animate chats moving between pinned and regular sections.

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

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes
  • I included a video for animation/interaction changes

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 / @t3tools to V12 / @v12: root and app package.json names, desktop productName, 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 / .t3 to ~/.v12 / .v12, desktop user-data folder names (V12 (Dev) / V12 (Alpha)), custom URL schemes v12 / v12-dev, and backend readiness /.well-known/v12/environment. Environment variables become V12_* (V12_HOME, V12_PORT, V12_CLERK_*, V12_RELAY_*, OTLP and web deploy vars). .env.example and dev-runner/desktop scripts follow the same naming (including V12_DEV_REMOTE_V12_SERVER_ENTRY_PATH and 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.td drops 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

  • Adds the ability to pin threads within a project in the sidebar; pinned threads are shown first with a visual divider, support drag-and-drop reordering across the pin boundary, and persist pin state in uiStateStore.
  • Introduces thread forking via a new thread.fork command: the decider copies message history up to a selected message into a new thread, tracking parentThreadId and forkedFromMessageId through the schema, projection pipeline, and persistence layer (migration 033 adds the required columns).
  • Adds a TaskHud overlay replacing the PlanSidebar; it surfaces plan steps and contextual tasks, supports toggling, removing, and reordering steps, and appends pending tasks to outgoing prompts.
  • Adds a "Queue message behind active run" action in ComposerPrimaryActions so users can submit while a run is in progress.
  • Replaces the "Revert to checkpoint" action with "Edit and rerun": interrupts active work, waits for thread settlement, forks a recovery thread, and restores the original message and attachments into the composer.
  • The entire codebase is rebranded from @t3tools/T3 Code to @v12/V12, including package names, DI service keys, environment variables, storage keys, URL schemes, JWT claims, and API endpoints.
  • Risk: the @t3tools@v12 rename changes DI service identifiers, JWT typ/iss/aud claim 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.

blacksmith-sh Bot and others added 2 commits February 27, 2026 22:01
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.
@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c47995d4-f6d9-49d1-931d-e68f5d27a2a9

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list. labels Jul 12, 2026
Comment thread apps/web/src/components/Sidebar.tsx
12ya added 2 commits July 13, 2026 09:32
Track row hover and focus directly instead of relying on nested group selectors, and hide row actions while a thread drag is active.
@12ya
12ya marked this pull request as ready for review July 13, 2026 00:36
Comment thread apps/web/src/components/Sidebar.tsx
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.
Comment thread apps/web/src/components/Sidebar.tsx
Comment thread apps/web/src/components/Sidebar.tsx
Comment thread apps/web/src/components/Sidebar.tsx
@macroscopeapp

macroscopeapp Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: 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.

Comment thread apps/web/src/components/Sidebar.tsx Outdated
12ya added 2 commits July 13, 2026 09:49
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.
Comment thread apps/web/src/components/Sidebar.tsx
Comment thread apps/web/src/components/Sidebar.tsx Outdated
Comment thread apps/web/src/components/Sidebar.logic.ts
12ya added 7 commits July 13, 2026 22:10
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.
@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Jul 16, 2026
Comment thread apps/desktop/src/app/DesktopEnvironment.ts
? 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"));

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.

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)
Fix in Cursor Fix in Web

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(

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 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);
}

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.

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)
Fix in Cursor Fix in Web

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.",

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.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bd2bd65. Configure here.

threadId: command.threadId,
proposedPlan,
},
});

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.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bd2bd65. Configure here.

readonly status: OpenThreadActivityPresentation | null;
readonly variant?: "pill" | "toolbar";
}) {
const [nowMs, setNowMs] = useState(() => Date.now());

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/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.

Comment on lines +807 to +811
if (
next.activityKind !== "tool.started" &&
next.activityKind !== "tool.updated" &&
next.activityKind !== "tool.completed"
) {

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/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") {

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/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.

Comment on lines +43 to +62
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;
}

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/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.

Suggested change
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.

Comment on lines +1073 to +1077
const selection = readSelectionWithin(container);
if (selection) {
setSelectedQuote(selection.quote);
setSelectionAnchorRect(selection.anchorRect);
}

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 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}`)

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 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 {

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 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

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.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c0eb2ba. Configure here.

12ya commented Jul 16, 2026

Copy link
Copy Markdown
Author

Closing this accidental PR in favor of #4047. This PR's head tracked the fork's long-lived main branch, so unrelated V12 development kept leaking into the diff. The replacement is rebuilt from current upstream main and contains only the sidebar pinning changes.

@12ya 12ya closed this Jul 16, 2026
Comment on lines +62 to +64
if (currentRouteTarget?.kind === "draft") {
await materializeDraftThread(currentRouteTarget.draftId);
}

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 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.

Suggested change
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);

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 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 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.

🤖 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 ||

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: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.

Suggested change
(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.

@cursor cursor Bot left a comment

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.

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).

Fix All in Cursor

❌ 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,
},

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.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit dd9d094. Configure here.

input: {
threadId: draftSession.threadId,
projectId: draftSession.projectId,
title: trimmedPrompt.length > 0 ? truncate(trimmedPrompt) : "Draft",

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.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit dd9d094. Configure here.

throw squashAtomCommandFailure(result);
}
useComposerDraftStore.getState().markDraftThreadPromoting(draftId, threadRef);
return true;

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.

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)
Fix in Cursor Fix in Web

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);
}

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.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit dd9d094. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant