Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -6870,6 +6871,7 @@ export default function ChatView(props: ChatViewProps) {
supportsPinning,
supportsSettlement,
confirmAndUnpinThread,
isWorktreeCardPinned,
copyActiveThreadReference,
getShortcutContext,
toggleRightPanel,
Expand Down
39 changes: 39 additions & 0 deletions apps/web/src/components/Sidebar.drag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
30 changes: 26 additions & 4 deletions apps/web/src/components/Sidebar.drag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -106,18 +107,25 @@ 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<Layout, "rects" | "activeIndex" | "overIndex"> | undefined;
let transforms: ReturnType<SortingStrategy>[] | null = [];

function project({ rects, activeIndex, overIndex }: Layout) {
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<SidebarSection, ThreadItem[]> = {
pinned: [],
active: [],
Expand All @@ -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 =
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,9 @@ export function planSidebarThreadDrop(input: {
readonly activeOrder: readonly string[];
readonly activeKeysById: ReadonlyMap<string, string | null | undefined>;
readonly activeReorderableKeys?: ReadonlySet<string>;
/** 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,
Expand Down Expand Up @@ -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" };
Expand Down Expand Up @@ -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" };
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/components/Sidebar.pointer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<typeof setTimeout> | null = null;
private readonly pointer: PointerEvent;
private readonly document: Document;
private readonly window: Window;
Expand All @@ -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 = () => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 });
Expand Down
Loading
Loading