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
102 changes: 88 additions & 14 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -435,19 +435,19 @@ function SnoozePopoverButton(props: {
);
}

// Subset of useSortable applied to a pinned card's root <li>. Listeners go
// on the whole card (no dedicated handle): the pointer sensor's distance
// Subset of useSortable applied to a thread row's root <li>. Listeners go
// on the whole row (no dedicated handle): the pointer sensor's distance
// constraint keeps plain clicks working, and we skip dnd-kit's aria
// attributes since there is no keyboard sensor and the card body already
// carries its own button semantics.
type SortablePinnedRowBag = Pick<
type SortableThreadRowBag = Pick<
ReturnType<typeof useSortable>,
"listeners" | "setNodeRef" | "transform" | "transition" | "isDragging"
>;

function SortablePinnedThreadRow(props: {
function SortableThreadRow(props: {
id: string;
children: (bag: SortablePinnedRowBag) => ReactNode;
children: (bag: SortableThreadRowBag) => ReactNode;
}) {
const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.id,
Expand Down Expand Up @@ -710,10 +710,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
// the descriptor is not loaded. Pinning itself lives in the context menu.
pinningSupported: boolean;
isPinned: boolean;
// Present only on pinned cards whose server supports reordering: dnd-kit
// sortable bag applied to the card root so the whole card drags (the
// pointer sensor's distance constraint keeps plain clicks working).
sortable?: SortablePinnedRowBag | undefined;
// Present only on rows that can be reordered: dnd-kit applies the sortable
// bag to the card root so the whole card drags (the pointer sensor's
// distance constraint keeps plain clicks working).
sortable?: SortableThreadRowBag | undefined;
// 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
Expand Down Expand Up @@ -1710,6 +1710,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
export default function Sidebar() {
const projects = useProjects();
const projectOrder = useUiStateStore((store) => store.projectOrder);
const threadOrder = useUiStateStore((store) => store.threadOrder);
const reorderThreadOrder = useUiStateStore((store) => store.reorderThreads);
const threads = useThreadShells();
const router = useRouter();
const { isMobile, setOpenMobile } = useSidebar();
Expand Down Expand Up @@ -2576,6 +2578,29 @@ export default function Sidebar() {
override holds until all of them appear in canonical state. */
readonly assignedKeys: ReadonlyMap<string, string>;
} | null>(null);
const activeThreadKeys = useMemo(
() =>
activeThreads.map((thread) =>
scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
),
[activeThreads],
);
const currentActiveThreadOrder = useMemo(() => {
const activeKeys = new Set(activeThreadKeys);
const savedOrder = threadOrder.filter((threadKey) => activeKeys.has(threadKey));
const savedKeys = new Set(savedOrder);
const newThreadKeys = activeThreadKeys.filter((threadKey) => !savedKeys.has(threadKey));
return [...newThreadKeys, ...savedOrder];
}, [activeThreadKeys, threadOrder]);
const orderedActiveThreads = useMemo(
() =>
orderItemsByPreferredIds({
items: activeThreads,
preferredIds: currentActiveThreadOrder,
getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
}),
[activeThreads, currentActiveThreadOrder],
);
const orderedPinnedThreads = useMemo(() => {
if (optimisticPinnedOrder === null) return pinnedThreads;
return orderItemsByPreferredIds({
Expand Down Expand Up @@ -2729,6 +2754,24 @@ export default function Sidebar() {
},
[orderedPinnedThreads, reorderPinnedThread, reorderablePinnedKeys],
);
const threadDndSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
);
const handleActiveThreadDragEnd = useCallback(
(event: DragEndEvent) => {
const activeKey = String(event.active.id);
const overKey = event.over === null ? null : String(event.over.id);
if (overKey === null || activeKey === overKey) return;
if (
!currentActiveThreadOrder.includes(activeKey) ||
!currentActiveThreadOrder.includes(overKey)
) {
return;
}
reorderThreadOrder(currentActiveThreadOrder, [activeKey], [overKey]);
},
[currentActiveThreadOrder, reorderThreadOrder],
);
// One snooze per thread at a time — same double-dispatch guard as settle.
const snoozingThreadKeysRef = useRef(new Set<string>());
const performSnooze = useCallback(
Expand Down Expand Up @@ -3652,7 +3695,7 @@ export default function Sidebar() {
const renderThreadRow = (
thread: EnvironmentThreadShell,
section: "pinned" | "active" | "snoozed" | "settled",
sortable?: SortablePinnedRowBag,
sortable?: SortableThreadRowBag,
) => {
const threadKey = scopedThreadKey(
scopeThreadRef(thread.environmentId, thread.id),
Expand Down Expand Up @@ -3800,9 +3843,9 @@ export default function Sidebar() {
return renderThreadRow(thread, "pinned");
}
return (
<SortablePinnedThreadRow key={threadKey} id={threadKey}>
<SortableThreadRow key={threadKey} id={threadKey}>
{(bag) => renderThreadRow(thread, "pinned", bag)}
</SortablePinnedThreadRow>
</SortableThreadRow>
);
})}
</ul>
Expand All @@ -3821,8 +3864,39 @@ export default function Sidebar() {
/>,
);
}
for (const thread of activeThreads) {
items.push(renderThreadRow(thread, "active"));
if (orderedActiveThreads.length > 0) {
items.push(
<li key="active-dnd" className="list-none">
<DndContext
sensors={threadDndSensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
onDragEnd={handleActiveThreadDragEnd}
>
<SortableContext
items={currentActiveThreadOrder}
strategy={verticalListSortingStrategy}
>
<ul
role="list"
aria-label="Active threads"
className="flex flex-col gap-px"
>
{orderedActiveThreads.map((thread) => {
const threadKey = scopedThreadKey(
scopeThreadRef(thread.environmentId, thread.id),
);
return (
<SortableThreadRow key={threadKey} id={threadKey}>
{(bag) => renderThreadRow(thread, "active", bag)}
</SortableThreadRow>
);
})}
</ul>
</SortableContext>
</DndContext>
</li>,
);
}
// Snoozed shelf: between the inbox and Settled — out of the
// way, never gone. The header always renders while anything
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/uiStateStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type PersistedUiState,
persistState,
reorderProjects,
reorderThreads,
resolveProjectExpanded,
setDefaultAdvertisedEndpointKey,
setProjectExpanded,
Expand All @@ -21,6 +22,7 @@ function makeUiState(overrides: Partial<UiState> = {}): UiState {
return {
projectExpandedById: {},
projectOrder: [],
threadOrder: [],
threadLastVisitedAtById: {},
threadChangedFilesExpandedById: {},
defaultAdvertisedEndpointKey: null,
Expand Down Expand Up @@ -116,6 +118,14 @@ describe("uiStateStore pure functions", () => {
);
});

it("reorders chats from the current sidebar order", () => {
const currentOrder = ["thread-1", "thread-2", "thread-3"];

const next = reorderThreads(makeUiState(), currentOrder, ["thread-1"], ["thread-3"]);

expect(next.threadOrder).toEqual(["thread-2", "thread-3", "thread-1"]);
});

it("stores explicit changed-file expansion choices", () => {
const threadId = ThreadId.make("thread-1");
const collapsed = setThreadChangedFilesExpanded(makeUiState(), threadId, "turn-1", false);
Expand Down Expand Up @@ -154,6 +164,7 @@ describe("parsePersistedState", () => {
invalid: "no" as unknown as boolean,
},
projectOrder: ["physical-b", "", "physical-a", "physical-b"],
threadOrder: ["environment:thread-2", "environment:thread-1", "environment:thread-2"],
threadLastVisitedAtById: {
"environment:thread-1": "2026-02-25T12:35:00.000Z",
invalid: "not-a-date",
Expand All @@ -173,6 +184,7 @@ describe("parsePersistedState", () => {
logical: false,
},
projectOrder: ["physical-b", "physical-a"],
threadOrder: ["environment:thread-2", "environment:thread-1"],
threadLastVisitedAtById: {
"environment:thread-1": "2026-02-25T12:35:00.000Z",
},
Expand Down Expand Up @@ -270,6 +282,7 @@ describe("uiStateStore persistence", () => {
logical: false,
},
projectOrder: ["physical-b", "physical-a"],
threadOrder: ["environment:thread-2", "environment:thread-1"],
threadLastVisitedAtById: {
"environment:thread-1": "2026-02-25T12:35:00.000Z",
},
Expand All @@ -292,6 +305,7 @@ describe("uiStateStore persistence", () => {
logical: false,
},
projectOrder: ["physical-b", "physical-a"],
threadOrder: ["environment:thread-2", "environment:thread-1"],
threadLastVisitedAtById: {
"environment:thread-1": "2026-02-25T12:35:00.000Z",
},
Expand Down
55 changes: 55 additions & 0 deletions apps/web/src/uiStateStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const LEGACY_PERSISTED_STATE_KEYS = [
export interface PersistedUiState {
projectExpandedById?: Record<string, boolean>;
projectOrder?: string[];
threadOrder?: string[];
threadLastVisitedAtById?: Record<string, string>;
collapsedProjectCwds?: string[];
expandedProjectCwds?: string[];
Expand All @@ -35,6 +36,7 @@ export interface UiProjectState {
}

export interface UiThreadState {
threadOrder: string[];
threadLastVisitedAtById: Record<string, string>;
threadChangedFilesExpandedById: Record<string, Record<string, boolean>>;
}
Expand All @@ -48,6 +50,7 @@ export interface UiState extends UiProjectState, UiThreadState, UiEndpointState
const initialState: UiState = {
projectExpandedById: {},
projectOrder: [],
threadOrder: [],
threadLastVisitedAtById: {},
threadChangedFilesExpandedById: {},
defaultAdvertisedEndpointKey: null,
Expand Down Expand Up @@ -125,6 +128,7 @@ export function parsePersistedState(parsed: PersistedUiState): UiState {
return {
projectExpandedById,
projectOrder,
threadOrder: sanitizeStringArray(parsed.threadOrder),
threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById),
threadChangedFilesExpandedById:
parsed.threadChangedFilesExpansionVersion === THREAD_CHANGED_FILES_EXPANSION_VERSION
Expand Down Expand Up @@ -203,6 +207,7 @@ export function persistState(state: UiState): void {
JSON.stringify({
projectExpandedById,
projectOrder: state.projectOrder,
threadOrder: state.threadOrder,
threadLastVisitedAtById: state.threadLastVisitedAtById,
defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey,
threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION,
Expand Down Expand Up @@ -381,12 +386,60 @@ export function reorderProjects(
};
}

export function reorderThreads(
state: UiState,
currentThreadOrder: readonly string[],
draggedThreadIds: readonly string[],
targetThreadIds: readonly string[],
): UiState {
if (draggedThreadIds.length === 0) {
return state;
}
const draggedSet = new Set(draggedThreadIds);
const targetSet = new Set(targetThreadIds);
if (draggedThreadIds.every((id) => targetSet.has(id))) {
return state;
}

const originalTargetIndex = currentThreadOrder.findIndex((id) => targetSet.has(id));
if (originalTargetIndex < 0) {
return state;
}

const threadOrder = [...currentThreadOrder];
const removed: string[] = [];
let draggedBeforeTarget = 0;
for (let i = threadOrder.length - 1; i >= 0; i--) {
if (draggedSet.has(threadOrder[i]!)) {
removed.unshift(threadOrder.splice(i, 1)[0]!);
if (i < originalTargetIndex) {
draggedBeforeTarget++;
}
}
}
if (removed.length === 0) {
return state;
}

const insertIndex = originalTargetIndex - Math.max(0, draggedBeforeTarget - 1);
threadOrder.splice(insertIndex, 0, ...removed);
return {
...state,
threadOrder,
};
}

interface UiStateStore extends UiState {
markThreadVisited: (threadId: string, visitedAt: string) => void;
markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void;
setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void;
setDefaultAdvertisedEndpointKey: (key: string | null) => void;
setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void;
reorderThreads: (
currentThreadOrder: readonly string[],
draggedThreadIds: readonly string[],
targetThreadIds: readonly string[],
) => void;
reorderProjects: (
currentProjectOrder: readonly string[],
draggedProjectIds: readonly string[],
Expand All @@ -406,6 +459,8 @@ export const useUiStateStore = create<UiStateStore>((set) => ({
set((state) => setDefaultAdvertisedEndpointKey(state, key)),
setProjectExpanded: (projectIds, expanded) =>
set((state) => setProjectExpanded(state, projectIds, expanded)),
reorderThreads: (currentThreadOrder, draggedThreadIds, targetThreadIds) =>
set((state) => reorderThreads(state, currentThreadOrder, draggedThreadIds, targetThreadIds)),
reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) =>
set((state) =>
reorderProjects(state, currentProjectOrder, draggedProjectIds, targetProjectIds),
Expand Down
Loading