diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c572f65cd832..e200f230ef6d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1455,7 +1455,8 @@ export default function ChatView(props: ChatViewProps) { const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null; const threadDetailLoading = threadSyncPhase === "loading"; const handleNewThread = useNewThreadHandler(); - const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions(); + const { settleThread, pinThread, confirmAndUnpinThread, isWorktreeCardPinned } = + useThreadActions(); const routeThreadRef = useMemo( () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], @@ -6664,7 +6665,7 @@ export default function ChatView(props: ChatViewProps) { event.preventDefault(); event.stopPropagation(); if (!isServerThread || !activeThreadRef || !supportsPinning) return; - const pinned = activeThreadPinned; + const pinned = activeThreadPinned || isWorktreeCardPinned(activeThreadRef); void (pinned ? confirmAndUnpinThread(activeThreadRef) : pinThread(activeThreadRef)).then( (result) => { if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; @@ -6870,6 +6871,7 @@ export default function ChatView(props: ChatViewProps) { supportsPinning, supportsSettlement, confirmAndUnpinThread, + isWorktreeCardPinned, copyActiveThreadReference, getShortcutContext, toggleRightPanel, diff --git a/apps/web/src/components/Sidebar.drag.test.ts b/apps/web/src/components/Sidebar.drag.test.ts index c757eca87aa4..a347e165548f 100644 --- a/apps/web/src/components/Sidebar.drag.test.ts +++ b/apps/web/src/components/Sidebar.drag.test.ts @@ -835,3 +835,42 @@ describe("lifted card clearance", () => { expect(511 + apply(511, 36, -500, 136, 114).y).toBe(250); }); }); + +describe("lifted worktree card projection", () => { + const items = [ + pinnedHeader, + divider, + thread("a1", "active"), + thread("a2", "active"), + thread("a3", "active"), + settledHeader, + thread("s1", "settled"), + ]; + + it("hides the siblings and opens a gap the height of the whole card", () => { + const result = preview( + { items, settledOrder: [], settledExpanded: true, cardKeys: ["a1", "a2"] }, + "a1", + "a3", + ); + // a2 travels inside the lifted card, so its own row leaves the flow. + expect(result.get("a2")?.scaleY).toBe(0); + // a3 slides up into the card's old slot (two rows plus the 1px gap). + expect(result.get("a3")).toEqual({ ...stationary, y: -166 }); + // The shelf below keeps its place: the gap below a3 is the card's height. + expect(result.get(sidebarMarkerId("settled-header"))).toEqual(stationary); + }); + + it("carries the siblings across the pinned divider with the dragged row", () => { + const result = preview( + { items, settledOrder: [], settledExpanded: true, cardKeys: ["a1", "a2"] }, + "a1", + sidebarMarkerId("pinned-header"), + ); + expect(result.get("a2")?.scaleY).toBe(0); + // The divider drops below the card; a3 stays put since the card left + // above it and re-entered above it. + expect(result.get(sidebarMarkerId("pinned-divider"))).toEqual({ ...stationary, y: 166 }); + expect(result.get("a3")).toEqual(stationary); + }); +}); diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts index bfd112d18998..e759367c5912 100644 --- a/apps/web/src/components/Sidebar.drag.ts +++ b/apps/web/src/components/Sidebar.drag.ts @@ -8,6 +8,7 @@ import { type SidebarListMarker, type SidebarSection, } from "./Sidebar.logic"; +import { applyWorktreeCardToDropTarget } from "./Sidebar.worktree"; const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 }; const hidden = { ...stationary, scaleY: 0 }; @@ -106,9 +107,14 @@ export function createSidebarSortingStrategy(input: { /** Space each pinned boundary opens for its label while dragging. The * markers stay zero height at rest, so nothing is reserved until pickup. */ boundaryLabelHeight?: number; + /** Every member of the lifted worktree card, dragged row included. The + * lifted row renders the whole card, so its siblings leave the flow and the + * gap opens to the card's full height. */ + cardKeys?: readonly string[]; }): SortingStrategy { const { items } = input; const indices = new Map(items.map((item, index) => [sidebarListItemId(item), index])); + const cardKeys = input.cardKeys ?? []; let previous: Pick | undefined; let transforms: ReturnType[] | null = []; @@ -116,8 +122,10 @@ export function createSidebarSortingStrategy(input: { const active = items[activeIndex]; const over = items[overIndex] ?? active; if (active?.kind !== "thread" || !over || !rects[0]) return []; - const target = resolveSidebarDropTarget(items, active.key, sidebarListItemId(over)); - if (!target) return []; + const plainTarget = resolveSidebarDropTarget(items, active.key, sidebarListItemId(over)); + if (!plainTarget) return []; + const target = applyWorktreeCardToDropTarget(plainTarget, active.key, cardKeys); + const siblings = new Set(cardKeys.filter((key) => key !== active.key)); const groups: Record = { pinned: [], active: [], @@ -135,10 +143,13 @@ export function createSidebarSortingStrategy(input: { } continue; } + // The lifted node may have been remeasured as a whole worktree card, + // so row heights only ever come from rows still in the flow. + if (item.key === active.key) continue; if (item.section === "pinned" || item.section === "active") cardHeight ??= rects[index]?.height; else slimHeight ??= rects[index]?.height; - if (item.key !== active.key) groups[item.section].push(item); + if (item.key !== active.key && !siblings.has(item.key)) groups[item.section].push(item); } // Cards are 4.875rem + 0.25rem padding; slim rows/placeholders are h-9. const scale = @@ -198,13 +209,24 @@ export function createSidebarSortingStrategy(input: { ? cardHeight : slimHeight; const moved = item.kind === "thread" && item.key === active.key; + // The lifted card is as tall as its members' rows plus the 1px list + // gaps between them: the siblings as measured at pickup, the dragged + // row as a plain row (its own node now holds the whole card). + const cardHeightTotal = + siblings.size === 0 + ? fallback + : cardKeys.reduce((sum, key) => { + const memberIndex = key === active.key ? undefined : indices.get(key); + const memberRect = memberIndex === undefined ? undefined : rects[memberIndex]; + return sum + (memberRect?.height ?? fallback) + 1; + }, -1); return item.kind === "marker" && (item.marker === "pinned-header" || item.marker === "pinned-divider") ? labelHeight : item.kind === "marker" && item.marker.endsWith("placeholder") ? slimHeight : moved - ? fallback + ? cardHeightTotal : (rect?.height ?? fallback); }); const firstShelf = items.findIndex( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 1bccaff80fad..b48c27f10859 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -250,6 +250,9 @@ export function planSidebarThreadDrop(input: { readonly activeOrder: readonly string[]; readonly activeKeysById: ReadonlyMap; readonly activeReorderableKeys?: ReadonlySet; + /** The lifted worktree card's members in card order; keys are assigned to + the whole block so the members stay adjacent on every client. */ + readonly movedIds?: readonly string[]; }): SidebarThreadDropPlan { const { activeKey, @@ -281,6 +284,7 @@ export function planSidebarThreadDrop(input: { orderedIds: order, keysById: activeKeysById, movedId: activeKey, + ...(input.movedIds === undefined ? {} : { movedIds: input.movedIds }), }); if (activeReorderableKeys && assignments.some(({ id }) => !activeReorderableKeys.has(id))) { return { kind: "none" }; @@ -310,6 +314,7 @@ export function planSidebarThreadDrop(input: { orderedIds: order, keysById: pinnedKeysById, movedId: activeKey, + ...(input.movedIds === undefined ? {} : { movedIds: input.movedIds }), }); if (reorderableKeys && assignments.some(({ id }) => !reorderableKeys.has(id))) { return { kind: "none" }; diff --git a/apps/web/src/components/Sidebar.pointer.ts b/apps/web/src/components/Sidebar.pointer.ts index f79bc06c64be..d11efb401dcb 100644 --- a/apps/web/src/components/Sidebar.pointer.ts +++ b/apps/web/src/components/Sidebar.pointer.ts @@ -10,10 +10,15 @@ export function SidebarDragLifecycle({ onUnmount }: { onUnmount: () => void }) { type Options = { distance: number; + /** A press held still this long (or Option at press) targets the row + * alone inside its worktree card; any earlier movement lifts the card. */ + holdMs: number; onAttach: (sensor: SidebarPointerSensor) => void; onFinish: (started: boolean) => void; }; +export type SidebarDragMode = "card" | "row"; + /** A sidebar gesture ends on release, cancellation, or loss of its window. * Own the listeners so unmounting the list can cancel the sensor too. */ export class SidebarPointerSensor { @@ -25,7 +30,10 @@ export class SidebarPointerSensor { }, ]; autoScrollEnabled = true; + /** Read at drag start; Sidebar decides whether the row has a card at all. */ + mode: SidebarDragMode = "card"; private phase: "pending" | "dragging" | "finished" = "pending"; + private holdTimer: ReturnType | null = null; private readonly pointer: PointerEvent; private readonly document: Document; private readonly window: Window; @@ -44,10 +52,23 @@ export class SidebarPointerSensor { this.window.addEventListener("resize", this.cancel); this.document.addEventListener("dragstart", this.preventDefault); this.document.addEventListener("contextmenu", this.preventDefault); + if (this.pointer.altKey) this.mode = "row"; + else { + this.holdTimer = setTimeout(() => { + this.holdTimer = null; + if (this.phase === "pending") this.mode = "row"; + }, props.options.holdMs); + } props.options.onAttach(this); props.onPending(props.active, { distance: props.options.distance }, this.coordinates()); } + private clearHoldTimer() { + if (this.holdTimer === null) return; + clearTimeout(this.holdTimer); + this.holdTimer = null; + } + private coordinates = () => ({ x: this.pointer.clientX, y: this.pointer.clientY }); private preventDefault = (event: Event) => event.preventDefault(); private clearClickSuppression = () => { @@ -81,6 +102,7 @@ export class SidebarPointerSensor { return; } this.phase = "dragging"; + this.clearHoldTimer(); this.document.addEventListener("click", this.suppressClick, { capture: true }); this.document.addEventListener("selectionchange", this.clearSelection); this.clearSelection(); @@ -111,6 +133,7 @@ export class SidebarPointerSensor { if (this.phase === "finished") return; const aborted = this.phase === "pending"; this.phase = "finished"; + this.clearHoldTimer(); this.document.removeEventListener("pointermove", this.move, { capture: true }); this.document.removeEventListener("pointerup", this.end, { capture: true }); this.document.removeEventListener("pointercancel", this.pointerCancel, { capture: true }); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f481d8be0943..1b2fe3bef486 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -119,7 +119,19 @@ import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; -import { useClientSettings } from "../hooks/useSettings"; +import { useClientSettings, useSidebarWorktreeCardsEnabled } from "../hooks/useSettings"; +import { + applyWorktreeCardToDropTarget, + dropSplitsForeignWorktreeCard, + gatherWorktreeCards, + resolveWorktreeCardPositions, + worktreeCardKey, + worktreeCardMembers, + worktreeCardOrderWithin, + worktreeCardSiblings, + worktreeCardStaysContiguous, + type WorktreeCardPosition, +} from "./Sidebar.worktree"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; @@ -193,7 +205,11 @@ import { createSidebarSortingStrategy, restrictBelowSidebarLabel, } from "./Sidebar.drag"; -import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; +import { + SidebarDragLifecycle, + SidebarPointerSensor, + type SidebarDragMode, +} from "./Sidebar.pointer"; import { createSidebarListMotion } from "./Sidebar.motion"; import { ThreadPullRequestBadgeControl, @@ -611,6 +627,10 @@ function SidebarSectionPlaceholder(props: { // sorting strategy opens 24px for a 16px label with 4px clearance on each side. const SIDEBAR_DRAG_LABEL_HEIGHT = 24; +// A press held still this long before moving drags the row alone inside its +// worktree card instead of the whole card (Option at press does the same). +const SIDEBAR_ROW_DRAG_HOLD_MS = 300; + function SidebarDragBoundary(props: { marker: "pinned-header" | "pinned-divider"; label: string; @@ -930,6 +950,26 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { ); }); +// Worktree card paint (Sidebar.worktree.ts): one well behind flush member +// rows, with the list's 1px gap as the hairline between them. Members drop +// the vertical padding at their shared edges; the well keeps the row's usual +// 2px inset at the card's top and bottom and adds one at the sides. +const worktreeCardRowClassName: Record = { + first: "rounded-t-lg bg-sidebar-foreground/[0.04] px-0.5 pb-0", + middle: "bg-sidebar-foreground/[0.04] px-0.5 py-0", + last: "rounded-b-lg bg-sidebar-foreground/[0.04] px-0.5 pt-0", +}; + +// The rows inside a lifted worktree card: not sortable themselves (the card's +// wrapper carries the dnd-kit node), but styled as dragging. +const liftedCardRowBag: SortableThreadRowBag = { + listeners: undefined, + setNodeRef: () => {}, + transform: null, + transition: undefined, + isDragging: true, +}; + // Verb and icon on the lifted row while it hovers over another section. Uses // the same icons as the row actions and context menu so the drop reads as the // action it performs. @@ -990,6 +1030,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // the pinned section. Any other position shows the verb badge instead, and // the badge carries its own icon. dragOverPinned: boolean; + // Members of the lifted worktree card; the verb badge counts them past one. + dropCount: number; + // Paint for a row inside a worktree card (Sidebar.worktree.ts); null + // outside a card. Applied to the list item only, the row body is untouched. + cardPosition: WorktreeCardPosition | null; // Compact wake countdown ("2h") for rows in the snoozed shelf. snoozeWakeLabelText: string | null; // When a snooze ended (timer or early wake); drives the Woke pill until @@ -1448,6 +1493,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { className="pointer-events-none ml-auto inline-flex h-5 shrink-0 items-center gap-1 rounded-sm border border-primary/40 bg-primary/10 px-1.5 text-[11px] font-medium text-primary" > {dropVerbBadge[props.dropVerb]} + {props.dropCount > 1 ? {props.dropCount} : null} ) : null; @@ -1737,6 +1783,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { className={cn( // Matches the h-[4.875rem] content box; the py-0.5 padding is added on top. "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_78px]", + props.cardPosition !== null && worktreeCardRowClassName[props.cardPosition], sortable?.isDragging && "relative z-20", )} > @@ -2139,6 +2186,7 @@ export default function Sidebar() { const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const timestampFormat = useClientSettings((s) => s.timestampFormat); + const worktreeCardsEnabled = useSidebarWorktreeCardsEnabled(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const { settleThread, @@ -2516,7 +2564,8 @@ export default function Sidebar() { // lifecycle command and any order-key writes. The next pickup waits for // this hold so a second drop cannot replace an unconfirmed placement. const [optimisticDrop, setOptimisticDrop] = useState<{ - readonly key: string; + /** Every thread the drop moved: one row, or a worktree card's members. */ + readonly keys: readonly string[]; readonly sourceSection: SidebarSection; readonly section: "pinned" | "active" | "settled"; readonly occurredAt: string; @@ -2571,7 +2620,7 @@ export default function Sidebar() { if (capabilities?.threadPinning === true && capabilities.threadPinReorder === true) { draggable.add(threadKey); } - if (optimisticDrop?.key === threadKey) { + if (optimisticDrop?.keys.includes(threadKey)) { const projected = applySidebarThreadDrop( thread, optimisticDrop.section, @@ -2606,25 +2655,32 @@ export default function Sidebar() { // web and mobile from the same data. const sortedPinned = sortPinnedThreadsForSidebar(pinned); const sortedActive = sortThreadsForSidebar(active); + const orderedPinned = + optimisticDrop?.section !== "pinned" || optimisticDrop.order === null + ? sortedPinned + : orderItemsByPreferredIds({ + items: sortedPinned, + preferredIds: optimisticDrop.order, + getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + }); + const orderedActive = + optimisticDrop?.section !== "active" || optimisticDrop.order === null + ? sortedActive + : orderItemsByPreferredIds({ + items: sortedActive, + preferredIds: optimisticDrop.order, + getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + }); + // Worktree cards gather behind their highest-ranking member on top of the + // flat order; a card with any pinned member lives in the pinned block. + const gathered = worktreeCardsEnabled + ? gatherWorktreeCards({ pinned: orderedPinned, active: orderedActive }) + : { pinned: orderedPinned, active: orderedActive }; return { - pinnedThreads: - optimisticDrop?.section !== "pinned" || optimisticDrop.order === null - ? sortedPinned - : orderItemsByPreferredIds({ - items: sortedPinned, - preferredIds: optimisticDrop.order, - getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - }), + pinnedThreads: gathered.pinned, draggableThreadKeys: draggable, activeReorderableThreadKeys: activeReorderable, - activeThreads: - optimisticDrop?.section !== "active" || optimisticDrop.order === null - ? sortedActive - : orderItemsByPreferredIds({ - items: sortedActive, - preferredIds: optimisticDrop.order, - getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - }), + activeThreads: gathered.active, // Soonest wake first: "what comes back next" is the shelf's question. snoozedThreads: snoozed.toSorted( (left, right) => @@ -2634,7 +2690,15 @@ export default function Sidebar() { settledThreads: sortSettledThreadsForSidebar(settled), snoozeNow: preciseNow, }; - }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); + }, [ + nowMinute, + optimisticDrop, + scopedProjectKeys, + serverConfigs, + snoozeWakeTick, + threads, + worktreeCardsEnabled, + ]); const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); @@ -3152,7 +3216,18 @@ export default function Sidebar() { readonly occurredAt: string; readonly activationY: number | null; readonly targetSection: SidebarSection | null; + /** "card" lifts the whole worktree card; "row" (Option, or a still + hold before moving) lifts the row alone, kept inside its card. */ + readonly mode: SidebarDragMode; + /** The dragged row's worktree card in card order; just the row when it + has no card. */ + readonly cardKeys: readonly string[]; } | null>(null); + // The card being carried as one, or undefined for a single-row drag. + const liftedCardKeys = + dragState !== null && dragState.mode === "card" && dragState.cardKeys.length > 1 + ? dragState.cardKeys + : undefined; const dragTargetSection = dragState?.targetSection ?? null; const dragSensorRef = useRef(null); const finishThreadDrag = useCallback((started: boolean) => { @@ -3171,6 +3246,7 @@ export default function Sidebar() { const dndSensors = useSensors( useSensor(SidebarPointerSensor, { distance: 6, + holdMs: SIDEBAR_ROW_DRAG_HOLD_MS, onAttach: attachDragSensor, onFinish: finishThreadDrag, }), @@ -3210,21 +3286,35 @@ export default function Sidebar() { thread, ]), ); - const thread = canonicalByKey.get(optimisticDrop.key); - if (thread === undefined || thread.archivedAt !== null) { + const members = optimisticDrop.keys.flatMap((key) => { + const thread = canonicalByKey.get(key); + return thread === undefined || thread.archivedAt !== null ? [] : [thread]; + }); + if (members.length !== optimisticDrop.keys.length) { setOptimisticDrop(null); return; } - const canonicalSection = effectiveSnoozed(thread, { now: new Date().toISOString() }) - ? "snoozed" - : thread.settledOverride === "settled" - ? "settled" - : thread.pinnedAt != null - ? "pinned" - : "active"; + const now = new Date().toISOString(); + const sections = members.map((thread): SidebarSection => + effectiveSnoozed(thread, { now }) + ? "snoozed" + : thread.settledOverride === "settled" + ? "settled" + : thread.pinnedAt != null + ? "pinned" + : "active", + ); + // A worktree card's unpinned members render in the pinned block, so + // between the two live sections any state is still "on its way". + const isLive = (section: SidebarSection) => section === "pinned" || section === "active"; + const liveMove = isLive(optimisticDrop.sourceSection) && isLive(optimisticDrop.section); if ( - canonicalSection !== optimisticDrop.sourceSection && - canonicalSection !== optimisticDrop.section + sections.some( + (section) => + section !== optimisticDrop.sourceSection && + section !== optimisticDrop.section && + !(liveMove && isLive(section)), + ) ) { setOptimisticDrop(null); return; @@ -3233,16 +3323,20 @@ export default function Sidebar() { // Settle also emits unpin/unsnooze events. Wait for the entire move // before releasing the projected fields and sort timestamps. if ( - canonicalSection === optimisticDrop.section && - thread.pinnedAt == null && - (!optimisticDrop.clearsSnooze || thread.snoozedUntil == null) + members.every( + (thread, index) => + sections[index] === optimisticDrop.section && + thread.pinnedAt == null && + (!optimisticDrop.clearsSnooze || thread.snoozedUntil == null), + ) ) { setOptimisticDrop(null); } return; } - if (canonicalSection !== optimisticDrop.section) return; - if (optimisticDrop.clearsSnooze && thread.snoozedUntil != null) return; + if (sections.some((section) => section !== optimisticDrop.section)) return; + if (optimisticDrop.clearsSnooze && members.some((thread) => thread.snoozedUntil != null)) + return; const destinationKeys = optimisticDrop.section === "pinned" ? pinnedKeys : activeKeys; const canonicalDestination = destinationKeys.flatMap((key) => { const canonical = canonicalByKey.get(key); @@ -3329,6 +3423,19 @@ export default function Sidebar() { } else { dragLabelOffsetRef.current = 0; } + const activeThread = threadByKey.get(activeKey); + const sectionList = + activeSection === "pinned" + ? pinnedThreads + : activeSection === "active" + ? activeThreads + : []; + const cardKeys = + worktreeCardsEnabled && activeThread !== undefined + ? worktreeCardMembers(sectionList, activeThread).map((member) => + scopedThreadKey(scopeThreadRef(member.environmentId, member.id)), + ) + : []; setDragState({ activeKey, activeSection, @@ -3336,9 +3443,14 @@ export default function Sidebar() { occurredAt: new Date().toISOString(), activationY: event.activatorEvent instanceof PointerEvent ? event.activatorEvent.clientY : null, + mode: + event.activatorEvent instanceof PointerEvent && event.activatorEvent.altKey + ? "row" + : (dragSensorRef.current?.mode ?? "card"), + cardKeys: cardKeys.length > 0 ? cardKeys : [activeKey], }); }, - [sectionByThreadKey], + [activeThreads, pinnedThreads, sectionByThreadKey, threadByKey, worktreeCardsEnabled], ); // Include every visible row in the measured order. Older servers disable // pickup on their rows without changing where those rows render. @@ -3433,6 +3545,21 @@ export default function Sidebar() { [sidebarListItems], ); const sortableIds = useMemo(() => sidebarListItems.map(sidebarListItemId), [sidebarListItems]); + // Card paint per row, from the gathered pinned and active lists. + const cardPositionByKey = useMemo(() => { + const positions = new Map(); + if (!worktreeCardsEnabled) return positions; + for (const list of [pinnedThreads, activeThreads]) { + const resolved = resolveWorktreeCardPositions(list); + list.forEach((thread, index) => { + const position = resolved[index]; + if (position) { + positions.set(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), position); + } + }); + } + return positions; + }, [activeThreads, pinnedThreads, worktreeCardsEnabled]); const draggedSettledOrder = useMemo(() => { const thread = dragState === null ? undefined : threadByKey.get(dragState.activeKey); if (dragState === null || thread === undefined) return []; @@ -3453,9 +3580,11 @@ export default function Sidebar() { settledVisibleCount, routeThreadKey, snoozedThreadCount: snoozedThreads.length, + ...(liftedCardKeys === undefined ? {} : { cardKeys: liftedCardKeys }), }), [ draggedSettledOrder, + liftedCardKeys, routeThreadKey, settledShelfExpanded, settledVisibleCount, @@ -3483,74 +3612,69 @@ export default function Sidebar() { [threads], ); const draggedThreadKey = dragState?.activeKey; - const draggedFromSection = dragState?.activeSection; + const draggedMode = dragState?.mode; + const draggedCardKeys = dragState?.cardKeys; const dragActivationY = dragState?.activationY; - const dndCollisionDetection = useMemo(() => { - if (draggedThreadKey === undefined || draggedFromSection === undefined) - return createSidebarCollisionDetection(() => true); - const source = threadByKey.get(draggedThreadKey); - if (source === undefined) return createSidebarCollisionDetection(() => false); - return createSidebarCollisionDetection( - (id) => { - const target = resolveSidebarDropTarget(sidebarListItems, draggedThreadKey, id); - if (target === null) return false; - return ( - planSidebarThreadDrop({ - activeKey: draggedThreadKey, - activeSection: draggedFromSection, - activePinned: source.pinnedAt != null, - activeSettled: source.settledOverride === "settled", - supportsSettlement: - serverConfigs.get(source.environmentId)?.environment.capabilities.threadSettlement === - true, - target, - pinnedOrder: pinnedKeys, - pinnedKeysById, - reorderableKeys: draggableThreadKeys, - activeOrder: activeKeys, - activeKeysById, - activeReorderableKeys: activeReorderableThreadKeys, - }).kind !== "none" - ); - }, - { - items: sidebarListItems, - activationY: dragActivationY ?? null, - }, - ); - }, [ - activeKeysById, - pinnedKeysById, - serverConfigs, - activeKeys, - activeReorderableThreadKeys, - draggedThreadKey, - draggedFromSection, - dragActivationY, - draggableThreadKeys, - pinnedKeys, - sidebarListItems, - threadByKey, - ]); - const handleThreadDragEnd = useCallback( - (event: DragEndEvent) => { - const activeKey = String(event.active.id); + const cardKeyByThreadKey = useMemo( + () => + new Map( + threads.map((thread) => [ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + worktreeCardKey(thread), + ]), + ), + [threads], + ); + // The drop a slot would perform for the lifted row, or null when the slot + // is not offered. Shared by collision detection (which slots light up) and + // the drop itself. A lifted card moves as one block; a row lifted alone + // inside its card stays there; nothing lands between another card's rows. + const resolveThreadDrop = useCallback( + (activeKey: string, overId: string) => { const activeSection = sectionByThreadKey.get(activeKey); - const target = - event.over === null - ? null - : resolveSidebarDropTarget(sidebarListItems, activeKey, String(event.over.id)); - const activeThread = threadByKey.get(activeKey); - if (activeSection === undefined || target === null || activeThread === undefined) return; - const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); + const source = threadByKey.get(activeKey); + if (activeSection === undefined || source === undefined) return null; + const plain = resolveSidebarDropTarget(sidebarListItems, activeKey, overId); + if (plain === null) return null; + const lifted = draggedThreadKey === activeKey; + const cardKeys = lifted && draggedCardKeys !== undefined ? draggedCardKeys : [activeKey]; + const cardDrag = lifted && draggedMode === "card" && cardKeys.length > 1; + const target = cardDrag ? applyWorktreeCardToDropTarget(plain, activeKey, cardKeys) : plain; + const sectionOrder = + target.section === "pinned" + ? target.pinnedOrder + : target.section === "active" + ? target.activeOrder + : null; + const memberKeys = cardDrag ? cardKeys : [activeKey]; + const rowInCard = !cardDrag && cardKeys.length > 1; + if (rowInCard) { + if ( + target.section !== activeSection || + sectionOrder === null || + !worktreeCardStaysContiguous(sectionOrder, cardKeys) + ) { + return null; + } + } else if ( + worktreeCardsEnabled && + sectionOrder !== null && + dropSplitsForeignWorktreeCard( + sectionOrder, + memberKeys, + (key) => cardKeyByThreadKey.get(key) ?? null, + ) + ) { + return null; + } const plan = planSidebarThreadDrop({ activeKey, activeSection, - activePinned: activeThread.pinnedAt != null, - activeSettled: activeThread.settledOverride === "settled", + activePinned: source.pinnedAt != null, + activeSettled: source.settledOverride === "settled", supportsSettlement: - serverConfigs.get(activeThread.environmentId)?.environment.capabilities - .threadSettlement === true, + serverConfigs.get(source.environmentId)?.environment.capabilities.threadSettlement === + true, target, pinnedOrder: pinnedKeys, pinnedKeysById, @@ -3558,9 +3682,60 @@ export default function Sidebar() { activeOrder: activeKeys, activeKeysById, activeReorderableKeys: activeReorderableThreadKeys, + // Both gestures key the whole card: a card drag to land it as a + // block, a row drag so the card keeps its anchor (see + // worktreeCardOrderWithin). + ...(cardDrag + ? { movedIds: cardKeys } + : rowInCard && sectionOrder !== null + ? { movedIds: worktreeCardOrderWithin(sectionOrder, cardKeys) } + : {}), }); - if (plan.kind === "none") return; - if (plan.kind === "settle" && settlingThreadKeysRef.current.has(activeKey)) return; + return plan.kind === "none" ? null : { target, plan, memberKeys }; + }, + [ + activeKeys, + activeKeysById, + activeReorderableThreadKeys, + cardKeyByThreadKey, + draggableThreadKeys, + draggedCardKeys, + draggedMode, + draggedThreadKey, + pinnedKeys, + pinnedKeysById, + sectionByThreadKey, + serverConfigs, + sidebarListItems, + threadByKey, + worktreeCardsEnabled, + ], + ); + const dndCollisionDetection = useMemo(() => { + if (draggedThreadKey === undefined) return createSidebarCollisionDetection(() => true); + return createSidebarCollisionDetection( + (id) => resolveThreadDrop(draggedThreadKey, id) !== null, + { + items: sidebarListItems, + activationY: dragActivationY ?? null, + }, + ); + }, [draggedThreadKey, dragActivationY, resolveThreadDrop, sidebarListItems]); + const handleThreadDragEnd = useCallback( + (event: DragEndEvent) => { + const activeKey = String(event.active.id); + const activeSection = sectionByThreadKey.get(activeKey); + const resolved = + event.over === null ? null : resolveThreadDrop(activeKey, String(event.over.id)); + const activeThread = threadByKey.get(activeKey); + if (activeSection === undefined || resolved === null || activeThread === undefined) return; + const { target, plan, memberKeys } = resolved; + const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); + if ( + plan.kind === "settle" && + memberKeys.some((key) => settlingThreadKeysRef.current.has(key)) + ) + return; const assignments = plan.kind === "pin" ? [ @@ -3571,7 +3746,7 @@ export default function Sidebar() { ? plan.assignments : []; const drop = { - key: activeKey, + keys: memberKeys, sourceSection: activeSection, section: target.section, occurredAt: new Date().toISOString(), @@ -3605,21 +3780,40 @@ export default function Sidebar() { } return false; }; + // A worktree card's members share the drop: every lifecycle command + // below runs per member, the dragged row first. The sidebar writes + // its own keys, so pin and unpin run per thread here (scope + // "thread"), never fanning out a second time. + const members = memberKeys.flatMap((key) => { + const thread = threadByKey.get(key); + return thread === undefined + ? [] + : [{ key, thread, ref: scopeThreadRef(thread.environmentId, thread.id) }]; + }); switch (plan.kind) { case "settle": { - settlingThreadKeysRef.current.add(activeKey); - const navigateAfterSettle = planForwardNavigation(activeKey); - const settled = await run(settleThread(threadRef), "Failed to settle thread").finally( - () => settlingThreadKeysRef.current.delete(activeKey), - ); + // The member that owns the route decides where to go afterwards; + // the rest of the card is parking too, so it is skipped over. + const parkedKey = + memberKeys.find((key) => key === routeThreadKeyRef.current) ?? activeKey; + const navigateAfterSettle = planForwardNavigation(parkedKey, new Set(memberKeys)); + for (const member of members) { + settlingThreadKeysRef.current.add(member.key); + const settled = await run( + settleThread(member.ref), + "Failed to settle thread", + ).finally(() => settlingThreadKeysRef.current.delete(member.key)); + if (!settled) return; + } + const parked = members.find((member) => member.key === parkedKey); if ( - settled && + parked !== undefined && shouldNavigateAfterThreadPark({ - threadKey: activeKey, + threadKey: parkedKey, currentThreadKey: routeThreadKeyRef.current, action: "settle", now: new Date().toISOString(), - thread: readThreadShell(threadRef), + thread: readThreadShell(parked.ref), }) ) navigateAfterSettle?.(); @@ -3627,8 +3821,13 @@ export default function Sidebar() { } case "move-active": // The drag expresses unpin intent; button/menu confirmation is unchanged. - if (plan.unpin && !(await run(unpinThread(threadRef), "Failed to unpin thread"))) - return; + for (const member of members) { + if ( + member.thread.pinnedAt != null && + !(await run(unpinThread(member.ref, { scope: "thread" }), "Failed to unpin thread")) + ) + return; + } if ( plan.unsettle && !(await run(unsettleThread(threadRef), "Failed to un-settle thread")) @@ -3640,10 +3839,10 @@ export default function Sidebar() { case "pin": if ( !(await run( - pinThread( - threadRef, - plan.orderKey === undefined ? {} : { orderKey: plan.orderKey }, - ), + pinThread(threadRef, { + scope: "thread", + ...(plan.orderKey === undefined ? {} : { orderKey: plan.orderKey }), + }), "Failed to pin thread", )) ) @@ -3653,40 +3852,44 @@ export default function Sidebar() { break; } // Stop on failure; each successful key write remains a valid placement. + // In the pinned block, a thread without a pin of its own (a card + // member gathered behind a pinned sibling, or arriving with the + // dragged row) takes its key on the pin command instead: the server + // rejects reorder for unpinned threads. const keyWrites = plan.kind === "pin" ? plan.extraAssignments : plan.assignments; for (const assignment of keyWrites) { const thread = threadByKey.get(assignment.id); if (thread === undefined) continue; - if ( - !(await run( - (plan.kind === "move-active" ? reorderActiveThread : reorderPinnedThread)( - scopeThreadRef(thread.environmentId, thread.id), - assignment.orderKey, - ), - plan.kind === "move-active" - ? "Failed to reorder active threads" - : "Failed to reorder pinned threads", - )) - ) - return; + const ref = scopeThreadRef(thread.environmentId, thread.id); + const write = + target.section === "active" + ? run( + reorderActiveThread(ref, assignment.orderKey), + "Failed to reorder active threads", + ) + : thread.pinnedAt == null + ? run( + pinThread(ref, { scope: "thread", orderKey: assignment.orderKey }), + "Failed to pin thread", + ) + : run( + reorderPinnedThread(ref, assignment.orderKey), + "Failed to reorder pinned threads", + ); + if (!(await write)) return; } })(); }, [ activeKeysById, pinnedKeysById, - serverConfigs, - activeKeys, - activeReorderableThreadKeys, - draggableThreadKeys, pinThread, - pinnedKeys, planForwardNavigation, reorderPinnedThread, reorderActiveThread, + resolveThreadDrop, sectionByThreadKey, settleThread, - sidebarListItems, threadByKey, unpinThread, unsettleThread, @@ -4058,7 +4261,13 @@ export default function Sidebar() { const isRegeneratingTitle = thread.titleRegeneration != null; const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); - const isPinned = thread.pinnedAt != null; + // A card is pinned while any member is: an unpinned member of a + // pinned card offers "Unpin worktree", which unpins its siblings. + const worktreeSiblings = worktreeCardsEnabled + ? worktreeCardSiblings(threads, thread, { now: new Date().toISOString() }) + : []; + const isPinned = + thread.pinnedAt != null || worktreeSiblings.some((sibling) => sibling.pinnedAt != null); // Presets resolve at menu-open time (same as the popover). const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); // Sidebar rows hold shells; the transcript needs the full detail, @@ -4073,6 +4282,7 @@ export default function Sidebar() { branch: thread.branch ?? null, hasTranscript: transcriptMessages.length > 0, isPinned, + worktreeSiblingCount: worktreeSiblings.length, isSettled, isSnoozed, canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), @@ -4286,7 +4496,9 @@ export default function Sidebar() { serverConfigs, startThreadRename, updateThreadMetadata, + threads, timestampFormat, + worktreeCardsEnabled, ], ); @@ -4663,10 +4875,14 @@ export default function Sidebar() { thread: EnvironmentThreadShell, section: SidebarSection, sortable?: SortableThreadRowBag, + // Rows inside the lifted worktree card: the first one + // carries the verb badge for the whole card. + lifted?: { verbRow: boolean; cardPosition: WorktreeCardPosition }, ) => { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), ); + const verbRow = lifted?.verbRow ?? dragState?.activeKey === threadKey; // Settled and snoozed are the ONLY things that collapse a // row: every other thread is a full card. Density comes // from users (or the auto rules) actually parking work, @@ -4703,13 +4919,17 @@ export default function Sidebar() { isPinned={thread.pinnedAt != null} sortable={sortable} dropVerb={ - dragState?.activeKey === threadKey + verbRow && dragState !== null ? resolveSidebarDropVerb(dragState.activeSection, dragTargetSection) : null } - dragOverPinned={ - dragState?.activeKey === threadKey && dragTargetSection === "pinned" + dropCount={ + verbRow && dragState !== null ? dragState.cardKeys.length : 1 + } + cardPosition={ + lifted?.cardPosition ?? cardPositionByKey.get(threadKey) ?? null } + dragOverPinned={verbRow && dragTargetSection === "pinned"} snoozeWakeLabelText={ section === "snoozed" && thread.snoozedUntil != null ? snoozeWakeLabel(thread.snoozedUntil, { @@ -4774,6 +4994,40 @@ export default function Sidebar() { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), ); + // The lifted worktree card: the dragged row's sortable + // node wraps every member, painted as one card, while + // the members' own rows sit hidden in the flow. + const renderLiftedCard = (bag: SortableThreadRowBag) => ( +
  • +
      + {(liftedCardKeys ?? []).map((memberKey, index, keys) => { + const member = threadByKey.get(memberKey); + if (member === undefined) return null; + return renderThreadRowInner(member, section, liftedCardRowBag, { + verbRow: index === 0, + cardPosition: + index === 0 + ? "first" + : index === keys.length - 1 + ? "last" + : "middle", + }); + })} +
    +
  • + ); return ( - {(bag) => renderThreadRowInner(thread, section, bag)} + {(bag) => + liftedCardKeys !== undefined && dragState?.activeKey === threadKey + ? renderLiftedCard(bag) + : renderThreadRowInner(thread, section, bag) + } ); }; diff --git a/apps/web/src/components/Sidebar.worktree.test.ts b/apps/web/src/components/Sidebar.worktree.test.ts new file mode 100644 index 000000000000..f4e684e83e59 --- /dev/null +++ b/apps/web/src/components/Sidebar.worktree.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + applyWorktreeCardToDropTarget, + dropSplitsForeignWorktreeCard, + gatherWorktreeCards, + resolveWorktreeCardPositions, + worktreeCardKey, + worktreeCardMembers, + worktreeCardOrderWithin, + worktreeCardSiblings, + worktreeCardStaysContiguous, +} from "./Sidebar.worktree"; + +const thread = (id: string, worktreePath: string | null, environmentId = "env") => ({ + id, + environmentId, + worktreePath, +}); + +describe("gatherWorktreeCards", () => { + it("pulls later members up behind the highest-ranking one, keeping flat order", () => { + const { pinned, active } = gatherWorktreeCards({ + pinned: [], + active: [thread("a", "/wt/x"), thread("b", null), thread("c", "/wt/x"), thread("d", "/wt/y")], + }); + expect(pinned).toEqual([]); + expect(active.map((t) => t.id)).toEqual(["a", "c", "b", "d"]); + }); + + it("moves unpinned members into the pinned list when any member is pinned", () => { + const { pinned, active } = gatherWorktreeCards({ + pinned: [thread("p", null), thread("a", "/wt/x")], + active: [thread("b", null), thread("c", "/wt/x")], + }); + expect(pinned.map((t) => t.id)).toEqual(["p", "a", "c"]); + expect(active.map((t) => t.id)).toEqual(["b"]); + }); + + it("keeps the same path on different environments apart", () => { + const { active } = gatherWorktreeCards({ + pinned: [], + active: [thread("a", "/wt/x", "one"), thread("b", null), thread("c", "/wt/x", "two")], + }); + expect(active.map((t) => t.id)).toEqual(["a", "b", "c"]); + }); +}); + +describe("resolveWorktreeCardPositions", () => { + it("paints only runs of two or more", () => { + const positions = resolveWorktreeCardPositions([ + thread("a", "/wt/x"), + thread("b", "/wt/x"), + thread("c", "/wt/x"), + thread("d", "/wt/y"), + thread("e", null), + thread("f", null), + ]); + expect(positions).toEqual(["first", "middle", "last", null, null, null]); + }); +}); + +describe("worktreeCardMembers", () => { + it("returns the contiguous run around the thread", () => { + const list = [thread("a", "/wt/x"), thread("b", "/wt/x"), thread("c", null)]; + expect(worktreeCardMembers(list, list[1]!).map((t) => t.id)).toEqual(["a", "b"]); + expect(worktreeCardMembers(list, list[2]!).map((t) => t.id)).toEqual(["c"]); + }); +}); + +describe("applyWorktreeCardToDropTarget", () => { + it("moves the siblings with the dragged row as one block in card order", () => { + const target = applyWorktreeCardToDropTarget( + { section: "active", pinnedOrder: [], activeOrder: ["b", "x", "a", "c"] }, + "x", + ["x", "a", "c"], + ); + expect(target.activeOrder).toEqual(["b", "x", "a", "c"]); + const moved = applyWorktreeCardToDropTarget( + { section: "active", pinnedOrder: [], activeOrder: ["a", "c", "b", "x"] }, + "x", + ["x", "a", "c"], + ); + expect(moved.activeOrder).toEqual(["b", "x", "a", "c"]); + }); + + it("carries siblings across the pinned divider with the dragged row", () => { + const target = applyWorktreeCardToDropTarget( + { section: "pinned", pinnedOrder: ["p", "x"], activeOrder: ["a", "b"] }, + "x", + ["x", "a"], + ); + expect(target.pinnedOrder).toEqual(["p", "x", "a"]); + expect(target.activeOrder).toEqual(["b"]); + }); +}); + +describe("worktreeCardOrderWithin", () => { + it("lists the members in their section order", () => { + expect(worktreeCardOrderWithin(["b", "y", "x", "c"], ["x", "y"])).toEqual(["y", "x"]); + }); +}); + +describe("worktreeCardStaysContiguous", () => { + it("accepts reorders inside the card and rejects leaving it", () => { + expect(worktreeCardStaysContiguous(["b", "y", "x", "c"], ["x", "y"])).toBe(true); + expect(worktreeCardStaysContiguous(["x", "b", "y", "c"], ["x", "y"])).toBe(false); + }); +}); + +describe("dropSplitsForeignWorktreeCard", () => { + const cardKeyOf = (key: string) => (key.startsWith("w") ? "card" : null); + it("flags a slot between two members of another card", () => { + expect(dropSplitsForeignWorktreeCard(["w1", "x", "w2"], ["x"], cardKeyOf)).toBe(true); + expect(dropSplitsForeignWorktreeCard(["x", "w1", "w2"], ["x"], cardKeyOf)).toBe(false); + expect(dropSplitsForeignWorktreeCard(["w1", "w2", "w3"], ["w2"], cardKeyOf)).toBe(false); + }); +}); + +describe("worktreeCardSiblings", () => { + type Shell = Parameters[0][number]; + const shell = ( + id: string, + overrides: { + worktreePath?: string | null; + archivedAt?: string | null; + settledOverride?: Shell["settledOverride"]; + snoozedUntil?: string | null; + } = {}, + ): Shell => ({ + id, + environmentId: "env", + worktreePath: overrides.worktreePath === undefined ? "/wt/x" : overrides.worktreePath, + archivedAt: overrides.archivedAt ?? null, + settledOverride: overrides.settledOverride ?? null, + snoozedUntil: overrides.snoozedUntil ?? null, + snoozedAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + session: null, + latestTurn: null, + }); + it("excludes the thread itself and members that left the card", () => { + const now = "2026-09-17T12:00:00.000Z"; + const siblings = worktreeCardSiblings( + [ + shell("self"), + shell("live"), + shell("settled", { settledOverride: "settled" }), + shell("snoozed", { snoozedUntil: "2026-09-18T12:00:00.000Z" }), + shell("archived", { archivedAt: now }), + shell("elsewhere", { worktreePath: "/wt/y" }), + shell("root", { worktreePath: null }), + ], + shell("self"), + { now }, + ); + expect(siblings.map((s) => s.id)).toEqual(["live"]); + expect( + worktreeCardSiblings([shell("a")], shell("root", { worktreePath: null }), { now }), + ).toEqual([]); + expect(worktreeCardKey(shell("root", { worktreePath: " " }))).toBeNull(); + }); + + it("gives a parked thread no siblings, so it inherits no card state", () => { + const now = "2026-09-17T12:00:00.000Z"; + const live = [shell("live"), shell("other")]; + expect( + worktreeCardSiblings(live, shell("settled", { settledOverride: "settled" }), { now }), + ).toEqual([]); + expect( + worktreeCardSiblings(live, shell("snoozed", { snoozedUntil: "2026-09-18T12:00:00.000Z" }), { + now, + }), + ).toEqual([]); + }); +}); diff --git a/apps/web/src/components/Sidebar.worktree.ts b/apps/web/src/components/Sidebar.worktree.ts new file mode 100644 index 000000000000..cbcf49228b38 --- /dev/null +++ b/apps/web/src/components/Sidebar.worktree.ts @@ -0,0 +1,214 @@ +import { + effectiveSnoozed, + type ThreadSnoozeShell, +} from "@t3tools/client-runtime/state/thread-settled"; +import type { SidebarThreadSummary } from "../types"; +import type { SidebarDropTarget } from "./Sidebar.logic"; + +// ── Worktree cards ──────────────────────────────────────────────────── +// The default sidebar keeps a flat, server-ordered thread list. A worktree +// card is a read-time view over that list: the pinned and active members of +// one worktree are gathered to the slot of their highest-ranking member and +// painted as one card. Nothing is stored for the card itself. Writes happen +// only behind user gestures (a card drag fans key writes out to every +// member), so another client splitting the flat order can never leave a +// half card here, and a partial write still renders one whole card. + +export type WorktreeCardThread = { + readonly environmentId: string; + readonly id: string; + readonly worktreePath: string | null; +}; + +/** Card identity: a worktree path is only unique within an environment. + Null for root-checkout threads, which never join a card. */ +export function worktreeCardKey(thread: { + readonly environmentId: string; + readonly worktreePath: string | null; +}): string | null { + const path = thread.worktreePath?.trim(); + return path ? `${thread.environmentId}\u0000${path}` : null; +} + +/** + * Gather each worktree's members behind its highest-ranking member. Pinned + * ranks above active, so a card with any pinned member lands in the pinned + * list, unpinned members included (they carry no pin of their own until the + * card is touched again). Members keep their flat relative order. + */ +export function gatherWorktreeCards(input: { + readonly pinned: readonly T[]; + readonly active: readonly T[]; +}): { pinned: T[]; active: T[] } { + const membersByCard = new Map(); + const flat: { thread: T; section: "pinned" | "active" }[] = []; + for (const section of ["pinned", "active"] as const) { + for (const thread of input[section]) { + flat.push({ thread, section }); + const key = worktreeCardKey(thread); + if (key === null) continue; + const members = membersByCard.get(key); + if (members) members.push(thread); + else membersByCard.set(key, [thread]); + } + } + const emitted = new Set(); + const out = { pinned: [] as T[], active: [] as T[] }; + for (const { thread, section } of flat) { + const key = worktreeCardKey(thread); + if (key === null) { + out[section].push(thread); + continue; + } + if (emitted.has(key)) continue; + emitted.add(key); + out[section].push(...membersByCard.get(key)!); + } + return out; +} + +export type WorktreeCardPosition = "first" | "middle" | "last"; + +/** Card paint per row of a gathered section list. Null for rows outside any + card, including a worktree's lone thread, which renders as today. */ +export function resolveWorktreeCardPositions( + list: readonly T[], +): ReadonlyArray { + const positions: (WorktreeCardPosition | null)[] = list.map(() => null); + let start = 0; + while (start < list.length) { + const key = worktreeCardKey(list[start]!); + let end = start; + while (key !== null && end + 1 < list.length && worktreeCardKey(list[end + 1]!) === key) { + end += 1; + } + if (end > start) { + for (let index = start; index <= end; index += 1) { + positions[index] = index === start ? "first" : index === end ? "last" : "middle"; + } + } + start = end + 1; + } + return positions; +} + +/** The contiguous run of the same worktree around `thread` in a gathered + section list; just the thread itself outside a card. */ +export function worktreeCardMembers( + list: readonly T[], + thread: WorktreeCardThread, +): T[] { + const key = worktreeCardKey(thread); + const index = list.findIndex( + (candidate) => candidate.environmentId === thread.environmentId && candidate.id === thread.id, + ); + if (index === -1) return []; + if (key === null) return [list[index]!]; + let start = index; + while (start > 0 && worktreeCardKey(list[start - 1]!) === key) start -= 1; + let end = index; + while (end + 1 < list.length && worktreeCardKey(list[end + 1]!) === key) end += 1; + return list.slice(start, end + 1); +} + +/** Re-form the dragged card at the drop slot: the dragged row's siblings leave + wherever the plain drop target left them and follow it as one block, in + card order. */ +export function applyWorktreeCardToDropTarget( + target: SidebarDropTarget, + activeKey: string, + cardKeys: readonly string[], +): SidebarDropTarget { + if (cardKeys.length < 2 || !cardKeys.includes(activeKey)) return target; + const siblings = new Set(cardKeys.filter((key) => key !== activeKey)); + const place = (order: readonly string[]) => { + const stripped = order.filter((key) => !siblings.has(key)); + const at = stripped.indexOf(activeKey); + if (at === -1) return stripped; + stripped.splice(at, 1, ...cardKeys); + return stripped; + }; + return { + section: target.section, + pinnedOrder: place(target.pinnedOrder), + activeOrder: place(target.activeOrder), + }; +} + +/** The card's members in the order a section arranges them after a drop. + A row moved inside its card re-keys the whole card against the card's + outside neighbors: keying only the moved row would let the card's anchor + (its best key) change and gather the card somewhere else. */ +export function worktreeCardOrderWithin( + sectionOrder: readonly string[], + cardKeys: readonly string[], +): string[] { + const members = new Set(cardKeys); + return sectionOrder.filter((key) => members.has(key)); +} + +/** Row drags inside a card must leave the card whole: every member still + adjacent in the section order. */ +export function worktreeCardStaysContiguous( + order: readonly string[], + cardKeys: readonly string[], +): boolean { + const indices = cardKeys.map((key) => order.indexOf(key)); + if (indices.some((index) => index === -1)) return false; + return Math.max(...indices) - Math.min(...indices) === cardKeys.length - 1; +} + +/** A drop slot strictly inside another worktree's card would preview a split + that gather-at-read undoes on commit; such slots are not offered. */ +export function dropSplitsForeignWorktreeCard( + order: readonly string[], + blockKeys: readonly string[], + cardKeyOf: (key: string) => string | null, +): boolean { + const first = order.indexOf(blockKeys[0] ?? ""); + const last = order.indexOf(blockKeys.at(-1) ?? ""); + if (first === -1 || last === -1) return false; + const before = order[first - 1]; + const after = order[last + 1]; + if (before === undefined || after === undefined) return false; + const neighborCard = cardKeyOf(before); + return ( + neighborCard !== null && + neighborCard === cardKeyOf(after) && + neighborCard !== cardKeyOf(blockKeys[0] ?? "") + ); +} + +type WorktreeSiblingShell = WorktreeCardThread & + ThreadSnoozeShell & { + readonly archivedAt: string | null; + readonly settledOverride: SidebarThreadSummary["settledOverride"]; + readonly pinnedAt?: string | null | undefined; + }; + +function isLiveCardMember(shell: WorktreeSiblingShell, options: { readonly now: string }) { + return ( + shell.archivedAt === null && + shell.settledOverride !== "settled" && + !effectiveSnoozed(shell, options) + ); +} + +/** + * The other live members of a thread's worktree card, for actions that fan + * out (pin, unpin). Settled and snoozed threads have left the card, so they + * are never touched by a card gesture, and a parked thread has no card of + * its own to act on. + */ +export function worktreeCardSiblings( + shells: ReadonlyArray, + thread: WorktreeSiblingShell, + options: { readonly now: string }, +): T[] { + const key = worktreeCardKey(thread); + if (key === null || !isLiveCardMember(thread, options)) return []; + return shells.filter( + (shell) => + shell.id !== thread.id && worktreeCardKey(shell) === key && isLiveCardMember(shell, options), + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 502158687651..f41449364fca 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2019,6 +2019,7 @@ const LEGACY_FEATURE_TARGET_IDS: ReadonlySet = new Set([ "legacy-plan-mode", "legacy-context-window-indicator", "legacy-sidebar", + "sidebar-worktree-cards", ]); /** @@ -2106,6 +2107,21 @@ function LegacyFeaturesSection() { } /> + {settings.sidebarMode === "default" ? ( + + updateSettings({ sidebarGroupWorktreeThreads: Boolean(checked) }) + } + aria-label="Worktree cards" + /> + } + /> + ) : null} diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index ea9e1a88ec30..0d74a93680dd 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -364,7 +364,12 @@ describe("settings search targets", () => { const streaming = getSettingsSearchTargetScope("response-streaming")!; expect(streaming.scope).toBe("project-defaults"); expect(isSettingsSearchScopeAvailable(streaming.scope, "project")).toBe(true); - for (const id of ["legacy-plan-mode", "legacy-context-window-indicator", "legacy-sidebar"]) { + for (const id of [ + "legacy-plan-mode", + "legacy-context-window-indicator", + "legacy-sidebar", + "sidebar-worktree-cards", + ]) { expect(getSettingsSearchTargetScope(id)!.scope).toBeNull(); } }); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 72d89715ecc3..fc2dea862573 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -413,6 +413,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", searchTerms: ["project thread tree old flat list"], }, + { + id: "sidebar-worktree-cards", + title: "Worktree cards", + to: "/settings/general", + searchTerms: ["group worktree threads together card sidebar branch"], + }, { id: "keybindings", title: "Keybindings", diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 57d77f438ef9..bb8731013094 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -6,6 +6,7 @@ const baseState: ThreadActionMenuState = { branch: null, hasTranscript: false, isPinned: false, + worktreeSiblingCount: 0, isSettled: false, isSnoozed: false, canSnoozeNow: true, @@ -117,3 +118,17 @@ describe("buildThreadActionMenuItems", () => { expect(archiveItem?.disabled).toBe(true); }); }); + +describe("worktree card labels", () => { + it("names the worktree when the pin fans out to siblings", () => { + const labels = (state: ThreadActionMenuState) => + buildThreadActionMenuItems(state) + .filter((item) => item.id === "pin" || item.id === "unpin") + .map((item) => item.label); + expect(labels({ ...baseState, worktreeSiblingCount: 2 })).toEqual(["Pin worktree"]); + expect(labels({ ...baseState, worktreeSiblingCount: 2, isPinned: true })).toEqual([ + "Unpin worktree", + ]); + expect(labels(baseState)).toEqual(["Pin thread"]); + }); +}); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 84c8defcf810..c6bcaaed6474 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -36,6 +36,9 @@ export interface ThreadActionMenuState { */ readonly hasTranscript: boolean; readonly isPinned: boolean; + /** Live threads sharing this thread's worktree card. Pin and unpin fan out + to them, so the labels say "worktree" whenever there are any. */ + readonly worktreeSiblingCount: number; readonly isSettled: boolean; readonly isSnoozed: boolean; readonly canSnoozeNow: boolean; @@ -72,8 +75,16 @@ export function buildThreadActionMenuItems( ...(state.supports.pinning ? [ state.isPinned - ? { id: "unpin" as const, label: "Unpin thread", icon: "pin-off" } - : { id: "pin" as const, label: "Pin thread", icon: "pin" }, + ? { + id: "unpin" as const, + label: state.worktreeSiblingCount > 0 ? "Unpin worktree" : "Unpin thread", + icon: "pin-off", + } + : { + id: "pin" as const, + label: state.worktreeSiblingCount > 0 ? "Pin worktree" : "Pin thread", + icon: "pin", + }, ] : []), // Both lifecycle actions stay available on pinned threads: settling diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index d8923476d3dc..aecfeff9f2e1 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -382,6 +382,19 @@ export function useSidebarMode(): SidebarMode { return settingsHydrated ? sidebarMode : DEFAULT_SIDEBAR_MODE; } +/** + * Whether the default sidebar gathers a worktree's threads into one card + * (see docs/user/thread-sidebar.md "Worktree cards"). Off in other sidebar + * modes, so pin fan-out never fires where no card is drawn. + */ +export function useSidebarWorktreeCardsEnabled(): boolean { + const settingsHydrated = useClientSettingsHydrated(); + const settings = useClientSettingsValue(); + return ( + settingsHydrated && settings.sidebarMode === "default" && settings.sidebarGroupWorktreeThreads + ); +} + /** Read current settings for one environment, merged with client-local preferences. */ export function useEnvironmentSettings( environmentId: EnvironmentId, diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 969ccfe46b02..9ead57f98add 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -26,6 +26,7 @@ import { readEnvironmentSupportsTitleRegeneration, readThreadDetail, readThreadShell, + readThreadShells, useProjects, } from "../state/entities"; import { buildThreadTranscriptBlock } from "../lib/threadTranscript"; @@ -38,6 +39,8 @@ import { } from "../logicalProject"; import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; import { useUiStateStore } from "../uiStateStore"; +import { worktreeCardSiblings } from "../components/Sidebar.worktree"; +import { useSidebarWorktreeCardsEnabled } from "./useSettings"; import { useCopyToClipboard } from "./useCopyToClipboard"; import { useNewThreadHandler } from "./useHandleNewThread"; import { useClientSettings } from "./useSettings"; @@ -93,6 +96,7 @@ export function useThreadActionMenu(input: { archiveThread, deleteThread, } = useThreadActions(); + const worktreeCardsEnabled = useSidebarWorktreeCardsEnabled(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); @@ -156,10 +160,17 @@ export function useThreadActionMenu(input: { threadDetail?.messages.filter( (message) => !message.streaming && message.text.trim().length > 0, ) ?? []; + // A card is pinned while any member is: the menu offers "Unpin + // worktree" on an unpinned member of a pinned card. + const worktreeSiblings = worktreeCardsEnabled + ? worktreeCardSiblings(readThreadShells(), thread, { now: now.toISOString() }) + : []; const items = buildThreadActionMenuItems({ branch: thread.branch ?? null, hasTranscript: transcriptMessages.length > 0, - isPinned: thread.pinnedAt != null, + isPinned: + thread.pinnedAt != null || worktreeSiblings.some((sibling) => sibling.pinnedAt != null), + worktreeSiblingCount: worktreeSiblings.length, isSettled: supports.settlement && thread.settledOverride === "settled", isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), @@ -387,6 +398,7 @@ export function useThreadActionMenu(input: { unsettleThread, unsnoozeThread, updateThreadMetadata, + worktreeCardsEnabled, ], ); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 1be3689bab2b..48c9401e6e3a 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -42,7 +42,8 @@ import { useUiStateStore } from "../uiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; -import { useClientSettings } from "./useSettings"; +import { useClientSettings, useSidebarWorktreeCardsEnabled } from "./useSettings"; +import { worktreeCardSiblings } from "../components/Sidebar.worktree"; import { useAtomCommand } from "../state/use-atom-command"; export class ThreadArchiveBlockedError extends Schema.TaggedError()( @@ -96,15 +97,24 @@ export class ThreadSnoozeBlockedError extends Schema.TaggedError()( "ThreadPinningUnsupportedError", { @@ -144,6 +154,8 @@ export class ThreadActiveReorderUnsupportedError extends Schema.TaggedError Promise) | null; }) { const { confirm } = input; @@ -151,12 +163,18 @@ export async function requestThreadUnpinConfirmation(input: { return AsyncResult.success(true); } + const siblingCount = input.siblingCount ?? 0; return settlePromise(() => confirm( - [ - `Unpin thread "${input.title}"?`, - "This will move the thread out of your pinned section.", - ].join("\n"), + siblingCount > 0 + ? [ + `Unpin thread "${input.title}" and ${siblingCount} more in its worktree?`, + "This will move the whole worktree out of your pinned section.", + ].join("\n") + : [ + `Unpin thread "${input.title}"?`, + "This will move the thread out of your pinned section.", + ].join("\n"), ), ); } @@ -221,6 +239,20 @@ export function useThreadActions() { const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder); const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); const confirmThreadUnpin = useClientSettings((settings) => settings.confirmThreadUnpin); + const worktreeCardsEnabled = useSidebarWorktreeCardsEnabled(); + // The other live threads in the target's worktree card, or none when cards + // are off. Read at call time: the card is a view over current shells. + const readWorktreeSiblings = useCallback( + (target: ScopedThreadRef) => { + if (!worktreeCardsEnabled) return []; + const shell = readThreadShell(target); + if (!shell) return []; + return worktreeCardSiblings(readThreadShells(), shell, { + now: new Date().toISOString(), + }).filter((sibling) => sibling.environmentId === target.environmentId); + }, + [worktreeCardsEnabled], + ); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const clearProjectDraftThreadById = useComposerDraftStore( (store) => store.clearProjectDraftThreadById, @@ -596,8 +628,17 @@ export function useThreadActions() { [unsettleThreadMutation], ); + // A worktree card is pinned while any live member is, so a keyboard or + // header toggle on an unpinned member of a pinned card must unpin. + const isWorktreeCardPinned = useCallback( + (target: ScopedThreadRef) => + readThreadShell(target)?.pinnedAt != null || + readWorktreeSiblings(target).some((sibling) => sibling.pinnedAt != null), + [readWorktreeSiblings], + ); + const pinThread = useCallback( - async (target: ScopedThreadRef, opts: { orderKey?: string } = {}) => { + async (target: ScopedThreadRef, opts: { orderKey?: string; scope?: ThreadPinScope } = {}) => { // Version skew: never send the command to a server that predates it. if (!readEnvironmentSupportsPinning(target.environmentId)) { return AsyncResult.failure( @@ -615,22 +656,45 @@ export function useThreadActions() { // gets the default so the same action never places differently. // orderKey rides only to servers that decode it; pre-reorder servers // get the bare pin they understand and the thread stays keyless. - const orderKey = readEnvironmentSupportsPinReorder(target.environmentId) - ? (opts.orderKey ?? topOfPinnedRunOrderKey()) - : undefined; - return pinThreadMutation({ + const supportsReorder = readEnvironmentSupportsPinReorder(target.environmentId); + const runTop = smallestPinnedOrderKey(); + const orderKey = supportsReorder ? (opts.orderKey ?? topOfPinnedRunOrderKey()) : undefined; + const result = await pinThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId, ...(orderKey !== undefined ? { orderKey } : {}), }, }); + if (result._tag === "Failure" || (opts.scope ?? "worktree") === "thread") return result; + // Unpinned siblings follow the thread into the pinned block, keyed in + // sequence between it and the run's previous top so the card stays + // whole on every client. Stop on the first failure; each landed pin + // is a complete placement on its own. + let previous = orderKey ?? null; + for (const sibling of readWorktreeSiblings(target)) { + if (sibling.pinnedAt != null) continue; + const siblingKey = + supportsReorder && orderKey !== undefined + ? (pinOrderKeyBetween(previous, runTop) ?? undefined) + : undefined; + const siblingResult = await pinThreadMutation({ + environmentId: sibling.environmentId, + input: { + threadId: sibling.id, + ...(siblingKey !== undefined ? { orderKey: siblingKey } : {}), + }, + }); + if (siblingResult._tag === "Failure") return siblingResult; + previous = siblingKey ?? previous; + } + return result; }, - [pinThreadMutation], + [pinThreadMutation, readWorktreeSiblings], ); const unpinThread = useCallback( - async (target: ScopedThreadRef) => { + async (target: ScopedThreadRef, opts: { scope?: ThreadPinScope } = {}) => { if (!readEnvironmentSupportsPinning(target.environmentId)) { return AsyncResult.failure( Cause.fail( @@ -641,12 +705,29 @@ export function useThreadActions() { ), ); } - return unpinThreadMutation({ - environmentId: target.environmentId, - input: { threadId: target.threadId }, - }); + // A card is pinned while any member is, so an unpinned member can be + // asked to unpin its worktree: the thread itself is skipped, its + // pinned siblings are not. + const shell = readThreadShell(target); + const result = + shell?.pinnedAt == null && (opts.scope ?? "worktree") === "worktree" + ? AsyncResult.success(undefined) + : await unpinThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + if (result._tag === "Failure" || (opts.scope ?? "worktree") === "thread") return result; + for (const sibling of readWorktreeSiblings(target)) { + if (sibling.pinnedAt == null) continue; + const siblingResult = await unpinThreadMutation({ + environmentId: sibling.environmentId, + input: { threadId: sibling.id }, + }); + if (siblingResult._tag === "Failure") return siblingResult; + } + return result; }, - [unpinThreadMutation], + [readWorktreeSiblings, unpinThreadMutation], ); const confirmAndUnpinThread = useCallback( @@ -656,6 +737,8 @@ export function useThreadActions() { const confirmationResult = await requestThreadUnpinConfirmation({ enabled: confirmThreadUnpin, title: resolved?.thread.title ?? "this thread", + siblingCount: readWorktreeSiblings(target).filter((sibling) => sibling.pinnedAt != null) + .length, confirm: localApi ? (message) => localApi.dialogs.confirm(message) : null, }); if (confirmationResult._tag === "Failure") { @@ -666,7 +749,7 @@ export function useThreadActions() { } return unpinThread(target); }, - [confirmThreadUnpin, resolveThreadTarget, unpinThread], + [confirmThreadUnpin, readWorktreeSiblings, resolveThreadTarget, unpinThread], ); const reorderPinnedThread = useCallback( @@ -809,6 +892,7 @@ export function useThreadActions() { pinThread, unpinThread, confirmAndUnpinThread, + isWorktreeCardPinned, reorderPinnedThread, reorderActiveThread, }), @@ -816,6 +900,7 @@ export function useThreadActions() { archiveThread, confirmAndDeleteThread, confirmAndUnpinThread, + isWorktreeCardPinned, deleteThread, pinThread, reorderPinnedThread, diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index 019c9d53023a..4abea20c9749 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -5,17 +5,18 @@ Terms whose meaning matters across T3 Code. Architecture and lifecycle constrain ## Workspace and conversation -| Term | Meaning | -| -------------- | ------------------------------------------------------------------------------------------------- | -| Environment | One running server and the machine, credentials, workspace access, and state it owns. | -| Client | A web, desktop, or mobile UI connected to an environment. The desktop app can also host a server. | -| Project | An environment-local workspace record rooted at a directory. | -| Workspace root | The project's base filesystem directory on the environment. | -| Worktree | A separate Git checkout a thread can use instead of the project's main checkout. | -| Thread | The durable conversation and work history for a project. It survives provider process exits. | -| Turn | One user-to-agent work cycle. Provider work can finish before checkpoint and diff work settles. | -| Activity | A non-message timeline item, such as a tool action, approval, or failure. | -| T3 home | The base data directory. Runtime state normally lives under its `userdata` directory. | +| Term | Meaning | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| Environment | One running server and the machine, credentials, workspace access, and state it owns. | +| Client | A web, desktop, or mobile UI connected to an environment. The desktop app can also host a server. | +| Project | An environment-local workspace record rooted at a directory. | +| Workspace root | The project's base filesystem directory on the environment. | +| Worktree | A separate Git checkout a thread can use instead of the project's main checkout. | +| Worktree card | The sidebar's visual grouping of the active threads that share one worktree. A view over the flat thread order, not a stored object. | +| Thread | The durable conversation and work history for a project. It survives provider process exits. | +| Turn | One user-to-agent work cycle. Provider work can finish before checkpoint and diff work settles. | +| Activity | A non-message timeline item, such as a tool action, approval, or failure. | +| T3 home | The base data directory. Runtime state normally lives under its `userdata` directory. | ## Orchestration diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index b52332c2b0a7..24315adfde38 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -73,6 +73,20 @@ If dragging is unavailable for one environment, update the T3 Code server runnin environment. Pinned and active reordering require server support. Threads from older servers keep their default order until the server is updated. +## Worktree cards + +On web and desktop, the threads that share a worktree stay together in the sidebar as one card. +A card holds the worktree's pinned and active threads. Snoozed and settled threads leave the card +for their own shelves. Pinning a thread pins the rest of its worktree, and unpinning does the +same. Settle, snooze, and the other row actions still apply to one thread at a time. + +Drag any row to move the whole card. To reorder one thread inside its card, hold Option while +dragging, or hold the pointer still for a moment before you start to move. Drop a card on +**Settled** to settle every thread in it. + +Turn cards off in **Settings → General → Worktree cards**. The order is stored per thread on the +server, so mobile and your other devices show the same threads next to each other. + ## Settle finished work Choose **Settle thread** from its menu to move finished work out of the active list diff --git a/packages/client-runtime/src/state/threadSort.test.ts b/packages/client-runtime/src/state/threadSort.test.ts index 0e1fba746cf3..d8532d61d4ad 100644 --- a/packages/client-runtime/src/state/threadSort.test.ts +++ b/packages/client-runtime/src/state/threadSort.test.ts @@ -195,6 +195,43 @@ describe("planPinnedReorder with hidden rows", () => { }); }); +describe("planPinnedReorder with a moved block", () => { + it("keys every member between the block's neighbors, in block order", () => { + const keysById = new Map([ + ["a", "f"], + ["b", "t"], + ["x", "v"], + ["y", "w"], + ]); + const assignments = planPinnedReorder({ + orderedIds: ["a", "x", "y", "b"], + keysById, + movedId: "x", + movedIds: ["x", "y"], + }); + expect(assignments.map(({ id }) => id)).toEqual(["x", "y"]); + const [x, y] = assignments.map(({ orderKey }) => orderKey); + expect(x! > "f" && x! < y! && y! < "t").toBe(true); + }); + + it("rewrites the section when the block is not contiguous", () => { + const keysById = new Map([ + ["a", "f"], + ["x", "v"], + ["y", "w"], + ]); + const assignments = planPinnedReorder({ + orderedIds: ["x", "a", "y"], + keysById, + movedId: "x", + movedIds: ["x", "y"], + }); + expect(assignments.map(({ id }) => id)).toEqual(["x", "a", "y"]); + const keys = assignments.map(({ orderKey }) => orderKey); + expect(keys).toEqual([...keys].sort()); + }); +}); + describe("planPinnedMove", () => { it("moves a thread up with a single key write", () => { const assignments = planPinnedMove({ diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index 93878310839f..2420cff7754f 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -240,6 +240,10 @@ export function generateSpreadPinOrderKeys(count: number): string[] { * reordering shipped), the whole section gets fresh spread keys — a * one-time materialization; every move after that is single-write. Active * reordering uses the same planner with activeOrderKey values. + * + * A contiguous block of moved threads (the web sidebar's worktree card) + * passes `movedIds`: every member gets its own key between the block's two + * neighbors, in block order, so the members stay adjacent on every client. */ export function planPinnedReorder(input: { /** Thread ids in the desired visual order (after the move). */ @@ -247,24 +251,36 @@ export function planPinnedReorder(input: { /** Include retained keys from hidden rows; only orderedIds receive writes. */ readonly keysById: ReadonlyMap; readonly movedId: string; + /** Contiguous run in orderedIds that moved together; must include movedId. */ + readonly movedIds?: readonly string[]; }): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> { const { orderedIds, keysById, movedId } = input; + const movedIds = input.movedIds ?? [movedId]; const visibleIds = new Set(orderedIds); const reservedKeys = new Set( [...keysById].flatMap(([id, key]) => (!visibleIds.has(id) && key != null ? [key] : [])), ); - const movedIndex = orderedIds.indexOf(movedId); - if (movedIndex === -1) return []; - const beforeId = movedIndex > 0 ? orderedIds[movedIndex - 1] : null; - const afterId = movedIndex < orderedIds.length - 1 ? orderedIds[movedIndex + 1] : null; + const firstIndex = orderedIds.indexOf(movedIds[0] ?? movedId); + if (firstIndex === -1 || !movedIds.includes(movedId)) return []; + const lastIndex = firstIndex + movedIds.length - 1; + const contiguous = movedIds.every((id, offset) => orderedIds[firstIndex + offset] === id); + const beforeId = firstIndex > 0 ? orderedIds[firstIndex - 1] : null; + const afterId = lastIndex < orderedIds.length - 1 ? orderedIds[lastIndex + 1] : null; const beforeKey = beforeId != null ? (keysById.get(beforeId) ?? null) : null; const afterKey = afterId != null ? (keysById.get(afterId) ?? null) : null; const beforeUsable = beforeId === null || beforeKey != null; const afterUsable = afterId === null || afterKey != null; - if (beforeUsable && afterUsable) { - let key = pinOrderKeyBetween(beforeKey, afterKey); - while (key !== null && reservedKeys.has(key)) key = pinOrderKeyBetween(key, afterKey); - if (key !== null) return [{ id: movedId, orderKey: key }]; + if (contiguous && beforeUsable && afterUsable) { + const assignments: { id: string; orderKey: string }[] = []; + let previous = beforeKey; + for (const id of movedIds) { + let key = pinOrderKeyBetween(previous, afterKey); + while (key !== null && reservedKeys.has(key)) key = pinOrderKeyBetween(key, afterKey); + if (key === null) break; + assignments.push({ id, orderKey: key }); + previous = key; + } + if (assignments.length === movedIds.length) return assignments; } // Keyless neighbor (or corrupt keys): rewrite the section in the new order. const keys = generateSpreadPinOrderKeys(orderedIds.length + reservedKeys.size) diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index e13bb2dbb795..02dbb6fdea9d 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -495,6 +495,16 @@ describe("ClientSettings sidebar", () => { expect(decoded).not.toHaveProperty("sidebarV2ConfiguredByUser"); }); + it("groups worktree threads by default and accepts the toggle off", () => { + expect(decodeClientSettings({}).sidebarGroupWorktreeThreads).toBe(true); + expect( + decodeClientSettings({ sidebarGroupWorktreeThreads: false }).sidebarGroupWorktreeThreads, + ).toBe(false); + expect( + decodeClientSettingsPatch({ sidebarGroupWorktreeThreads: false }).sidebarGroupWorktreeThreads, + ).toBe(false); + }); + it("accepts each sidebar mode and rejects unknown ones", () => { for (const mode of ["default", "legacy", "connor-1"] as const) { expect(decodeClientSettings({ sidebarMode: mode }).sidebarMode).toBe(mode); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3a24b89ecb9c..3f5c6301920e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -189,6 +189,7 @@ const StoredSidebarMode = Schema.Literals([ ), ); export const DEFAULT_SIDEBAR_MODE: SidebarMode = "default"; +export const DEFAULT_SIDEBAR_GROUP_WORKTREE_THREADS = true; export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", "none"]); export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type; @@ -509,6 +510,12 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarMode: StoredSidebarMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_MODE)), ), + // Default sidebar only: threads that share a worktree render as one card + // (see docs/user/thread-sidebar.md "Worktree cards"). Fork-only; upstream + // behavior is the toggle off. + sidebarGroupWorktreeThreads: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_GROUP_WORKTREE_THREADS)), + ), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), @@ -1567,6 +1574,7 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), sidebarMode: Schema.optionalKey(SidebarMode), + sidebarGroupWorktreeThreads: Schema.optionalKey(Schema.Boolean), timestampFormat: Schema.optionalKey(TimestampFormat), snapShotEnabled: Schema.optionalKey(Schema.Boolean), snapShotIncludeAccessibility: Schema.optionalKey(Schema.Boolean),