Skip to content

Commit 5395c7f

Browse files
tim-smartgithub-actions[bot]
authored andcommitted
Paginate settled threads on the session board
- Share settled-tail page limits with the sidebar - Preserve whole worktree groups when slicing board results - Reset board pagination when the project filter changes
1 parent 41ba306 commit 5395c7f

5 files changed

Lines changed: 185 additions & 57 deletions

File tree

apps/web/src/components/Sidebar.logic.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100;
2222
// Visible sidebar rows are prewarmed into the thread-detail cache so opening a
2323
// nearby thread usually reuses an already-hot subscription.
2424
export const SIDEBAR_THREAD_PREWARM_LIMIT = 10;
25+
// Settled-tail paging: recent history is the common lookup; the deep tail
26+
// stays behind an explicit Show more. Shared by SidebarV2 and the board.
27+
export const SETTLED_TAIL_INITIAL_COUNT = 10;
28+
export const SETTLED_TAIL_PAGE_COUNT = 25;
2529
type SidebarProject = {
2630
id: string;
2731
title: string;

apps/web/src/components/SidebarV2.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"
103103
import type { SidebarThreadSummary } from "../types";
104104
import { cn } from "~/lib/utils";
105105
import {
106+
SETTLED_TAIL_INITIAL_COUNT,
107+
SETTLED_TAIL_PAGE_COUNT,
106108
formatWorkingDurationLabel,
107109
firstValidTimestampMs,
108110
hasUnseenCompletion,
@@ -152,10 +154,6 @@ import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
152154
import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
153155
import { useComposerDraftStore } from "../composerDraftStore";
154156

155-
// Settled-tail paging: recent history is the common lookup; the deep tail
156-
// stays behind an explicit Show more.
157-
const SETTLED_TAIL_INITIAL_COUNT = 10;
158-
const SETTLED_TAIL_PAGE_COUNT = 25;
159157
const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
160158
repository: "Group by repository",
161159
repository_path: "Group by repository path",

apps/web/src/components/board/Board.logic.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ import {
1010
boardWorktreeKey,
1111
buildBoardColumns,
1212
buildBoardProjectFilterPredicate,
13+
countBoardColumnThreads,
1314
deriveBoardColumn,
1415
parseBoardWorktreeGroupDragId,
1516
resolveBoardDropIntent,
17+
sliceBoardSettledItems,
1618
sortBoardThreads,
1719
type BoardColumnItem,
1820
type BoardColumnInput,
@@ -471,6 +473,68 @@ describe("buildBoardColumns", () => {
471473
});
472474
});
473475

476+
describe("countBoardColumnThreads", () => {
477+
it("counts a worktree group as its member count", () => {
478+
const items: BoardColumnItem<{ readonly id: string }>[] = [
479+
{ kind: "thread", thread: { id: "thread-1" } },
480+
{
481+
kind: "worktreeGroup",
482+
worktreeKey: "shared-worktree",
483+
threads: [{ id: "thread-2" }, { id: "thread-3" }],
484+
},
485+
];
486+
expect(countBoardColumnThreads(items)).toBe(3);
487+
expect(countBoardColumnThreads([])).toBe(0);
488+
});
489+
});
490+
491+
describe("sliceBoardSettledItems", () => {
492+
const thread = (id: string): BoardColumnItem<{ readonly id: string }> => ({
493+
kind: "thread",
494+
thread: { id },
495+
});
496+
const group = (
497+
worktreeKey: string,
498+
...ids: string[]
499+
): BoardColumnItem<{ readonly id: string }> => ({
500+
kind: "worktreeGroup",
501+
worktreeKey,
502+
threads: ids.map((id) => ({ id })),
503+
});
504+
505+
it("returns the same array with no hidden count when the total fits the limit", () => {
506+
const items = [thread("thread-1"), group("shared-worktree", "thread-2", "thread-3")];
507+
const result = sliceBoardSettledItems(items, 3);
508+
expect(result.visibleItems).toBe(items);
509+
expect(result.hiddenThreadCount).toBe(0);
510+
});
511+
512+
it("slices plain thread items at the limit", () => {
513+
const items = [thread("thread-1"), thread("thread-2"), thread("thread-3")];
514+
const result = sliceBoardSettledItems(items, 2);
515+
expect(columnThreadIds(result.visibleItems)).toEqual(["thread-1", "thread-2"]);
516+
expect(result.hiddenThreadCount).toBe(1);
517+
});
518+
519+
it("includes a group straddling the limit whole and counts its members", () => {
520+
const items = [
521+
thread("thread-1"),
522+
group("shared-worktree", "thread-2", "thread-3", "thread-4"),
523+
thread("thread-5"),
524+
];
525+
const result = sliceBoardSettledItems(items, 2);
526+
expect(result.visibleItems).toEqual([items[0], items[1]]);
527+
expect(result.hiddenThreadCount).toBe(1);
528+
});
529+
530+
it("returns no visible items for a zero limit with non-empty input", () => {
531+
const items = [thread("thread-1"), thread("thread-2")];
532+
const result = sliceBoardSettledItems(items, 0);
533+
expect(result.visibleItems).toEqual([]);
534+
expect(result.hiddenThreadCount).toBe(2);
535+
});
536+
});
537+
474538
describe("buildBoardProjectFilterPredicate", () => {
475539
const projectId = ProjectId.make("project-1");
476540
const otherProjectId = ProjectId.make("project-2");

apps/web/src/components/board/Board.logic.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,38 @@ export function buildBoardColumns<T extends BoardSortableThread>(
301301
return columns;
302302
}
303303

304+
/** Total threads across column items; a worktree group counts each member. */
305+
export function countBoardColumnThreads<T>(items: readonly BoardColumnItem<T>[]): number {
306+
return items.reduce(
307+
(count, item) => count + (item.kind === "thread" ? 1 : item.threads.length),
308+
0,
309+
);
310+
}
311+
312+
/**
313+
* Settled-tail slice for the board column. The limit counts threads (a
314+
* worktree group counts as its member count) so paging matches the sidebar's
315+
* thread-based tail; a group straddling the limit is included whole since a
316+
* group card cannot render partially.
317+
*/
318+
export function sliceBoardSettledItems<T>(
319+
items: readonly BoardColumnItem<T>[],
320+
limit: number,
321+
): { visibleItems: readonly BoardColumnItem<T>[]; hiddenThreadCount: number } {
322+
const total = countBoardColumnThreads(items);
323+
if (total <= limit) {
324+
return { visibleItems: items, hiddenThreadCount: 0 };
325+
}
326+
const visibleItems: BoardColumnItem<T>[] = [];
327+
let shown = 0;
328+
for (const item of items) {
329+
if (shown >= limit) break;
330+
visibleItems.push(item);
331+
shown += item.kind === "thread" ? 1 : item.threads.length;
332+
}
333+
return { visibleItems, hiddenThreadCount: total - shown };
334+
}
335+
304336
export interface BoardWorktreeThread {
305337
readonly environmentId: EnvironmentId;
306338
readonly worktreePath: string | null;

apps/web/src/components/board/BoardView.tsx

Lines changed: 83 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ import { useUiStateStore } from "../../uiStateStore";
5454
import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar";
5555
import { ProjectFavicon, ProjectFaviconFallback } from "../ProjectFavicon";
5656
import {
57+
SETTLED_TAIL_INITIAL_COUNT,
58+
SETTLED_TAIL_PAGE_COUNT,
5759
archiveSelectedThreadEntries,
5860
buildSidebarV2ThreadContextMenuItems,
5961
isThreadSettledForDisplay,
@@ -81,10 +83,11 @@ import {
8183
boardWorktreeKey,
8284
buildBoardColumns,
8385
buildBoardProjectFilterPredicate,
86+
countBoardColumnThreads,
8487
deriveBoardColumn,
8588
parseBoardWorktreeGroupDragId,
8689
resolveBoardDropIntent,
87-
type BoardColumnItem,
90+
sliceBoardSettledItems,
8891
type BoardDropIntent,
8992
} from "./Board.logic";
9093
import { BoardCard, BoardCardDragOverlay } from "./BoardCard";
@@ -105,13 +108,6 @@ interface BoardThreadGitContext {
105108
readonly gitStatusPending: boolean;
106109
}
107110

108-
function countBoardColumnThreads<T>(items: readonly BoardColumnItem<T>[]): number {
109-
return items.reduce(
110-
(count, item) => count + (item.kind === "thread" ? 1 : item.threads.length),
111-
0,
112-
);
113-
}
114-
115111
/** Error toast for a failed thread action; interruptions and successes are silent. */
116112
function reportThreadActionFailure(result: AtomCommandResult<unknown, unknown>, title: string) {
117113
if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) {
@@ -429,6 +425,24 @@ function BoardContent() {
429425
],
430426
);
431427

428+
// Settled tail renders in pages, mirroring SidebarV2: expansion resets when
429+
// the project filter changes so a scope flip never inherits a deep page.
430+
const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT);
431+
const settledResetKey = storedProjectFilter ?? "all";
432+
const lastSettledResetKeyRef = useRef(settledResetKey);
433+
if (lastSettledResetKeyRef.current !== settledResetKey) {
434+
lastSettledResetKeyRef.current = settledResetKey;
435+
setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT);
436+
}
437+
const settledTail = useMemo(
438+
() => sliceBoardSettledItems(columns.settled, settledVisibleCount),
439+
[columns, settledVisibleCount],
440+
);
441+
const showMoreSettled = useCallback(
442+
() => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT),
443+
[],
444+
);
445+
432446
const dragClickGuard = useMemo(() => createBoardDragClickGuard(), []);
433447
useEffect(() => () => dragClickGuard.dispose(), [dragClickGuard]);
434448
const [activeDragId, setActiveDragId] = useState<string | null>(null);
@@ -792,55 +806,71 @@ function BoardContent() {
792806
data-testid="board-column-row"
793807
className="flex h-full w-full min-w-max justify-center gap-3 p-3 sm:p-4"
794808
>
795-
{BOARD_COLUMN_IDS.map((columnId) => (
796-
<BoardColumn
797-
key={columnId}
798-
columnId={columnId}
799-
count={countBoardColumnThreads(columns[columnId])}
800-
>
801-
{columns[columnId].map((item) => {
802-
const renderCard = (thread: SidebarThreadSummary) => {
803-
const gitContext = getThreadGitContext(thread);
804-
const threadKey = scopedThreadKey(
805-
scopeThreadRef(thread.environmentId, thread.id),
806-
);
809+
{BOARD_COLUMN_IDS.map((columnId) => {
810+
const items = columnId === "settled" ? settledTail.visibleItems : columns[columnId];
811+
return (
812+
<BoardColumn
813+
key={columnId}
814+
columnId={columnId}
815+
count={countBoardColumnThreads(columns[columnId])}
816+
>
817+
{items.map((item) => {
818+
const renderCard = (thread: SidebarThreadSummary) => {
819+
const gitContext = getThreadGitContext(thread);
820+
const threadKey = scopedThreadKey(
821+
scopeThreadRef(thread.environmentId, thread.id),
822+
);
823+
return (
824+
<BoardCard
825+
key={threadKey}
826+
thread={thread}
827+
project={gitContext.project}
828+
gitStatus={gitContext.gitStatus}
829+
gitStatusPending={gitContext.gitStatusPending}
830+
isSettled={settledThreadKeys.has(threadKey)}
831+
onOpenThread={handleOpenThread}
832+
onShowContextMenu={showThreadContextMenu}
833+
dragClickGuard={dragClickGuard}
834+
/>
835+
);
836+
};
837+
if (item.kind === "thread") {
838+
return renderCard(item.thread);
839+
}
840+
// buildBoardColumns only emits groups with >= 2 members.
841+
const mostRecentThread = item.threads[0]!;
807842
return (
808-
<BoardCard
809-
key={threadKey}
810-
thread={thread}
811-
project={gitContext.project}
812-
gitStatus={gitContext.gitStatus}
813-
gitStatusPending={gitContext.gitStatusPending}
814-
isSettled={settledThreadKeys.has(threadKey)}
815-
onOpenThread={handleOpenThread}
816-
onShowContextMenu={showThreadContextMenu}
843+
<BoardWorktreeGroup
844+
key={item.worktreeKey}
845+
worktreeKey={item.worktreeKey}
846+
threadRefs={item.threads.map((thread) =>
847+
scopeThreadRef(thread.environmentId, thread.id),
848+
)}
849+
worktreePath={mostRecentThread.worktreePath ?? ""}
850+
branch={mostRecentThread.branch}
851+
mostRecentCard={renderCard(mostRecentThread)}
817852
dragClickGuard={dragClickGuard}
818-
/>
853+
>
854+
{item.threads.slice(1).map(renderCard)}
855+
</BoardWorktreeGroup>
819856
);
820-
};
821-
if (item.kind === "thread") {
822-
return renderCard(item.thread);
823-
}
824-
// buildBoardColumns only emits groups with >= 2 members.
825-
const mostRecentThread = item.threads[0]!;
826-
return (
827-
<BoardWorktreeGroup
828-
key={item.worktreeKey}
829-
worktreeKey={item.worktreeKey}
830-
threadRefs={item.threads.map((thread) =>
831-
scopeThreadRef(thread.environmentId, thread.id),
832-
)}
833-
worktreePath={mostRecentThread.worktreePath ?? ""}
834-
branch={mostRecentThread.branch}
835-
mostRecentCard={renderCard(mostRecentThread)}
836-
dragClickGuard={dragClickGuard}
857+
})}
858+
{columnId === "settled" && settledTail.hiddenThreadCount > 0 ? (
859+
<button
860+
type="button"
861+
onClick={showMoreSettled}
862+
data-testid="board-settled-show-more"
863+
className="mt-1 flex h-[30px] w-full items-center justify-center gap-1.5 rounded-md border border-dashed border-border font-mono text-[11px] text-muted-foreground transition-colors hover:border-solid hover:border-input hover:bg-background/45 hover:text-foreground dark:border-white/15 dark:hover:border-white/30 dark:hover:bg-transparent"
837864
>
838-
{item.threads.slice(1).map(renderCard)}
839-
</BoardWorktreeGroup>
840-
);
841-
})}
842-
</BoardColumn>
843-
))}
865+
Show {Math.min(settledTail.hiddenThreadCount, SETTLED_TAIL_PAGE_COUNT)} more
866+
<span className="text-muted-foreground/50">
867+
({settledTail.hiddenThreadCount} settled hidden)
868+
</span>
869+
</button>
870+
) : null}
871+
</BoardColumn>
872+
);
873+
})}
844874
</div>
845875
</div>
846876
{activeDragId !== null ? (

0 commit comments

Comments
 (0)