Desktop app: organize sidebar sessions into Pinned, Scheduled, and Tasks sections (#13528)

* Add Pinned/Scheduled/Tasks categories to desktop app sidebar

Replace the Schedules and Favorites filter-menu options with visible
collapsible category sections in the session sidebar, and rename the
Favorite action to Pin across the sidebar and sessions view.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* Grow full history window when Tasks show-more outpaces loaded tasks

loadMoreSessions treats its argument as a limit on all sessions, but the
Tasks show-more count only tracks Task rows, so once pinned/scheduled
rows pushed the loaded total past the requested count the call no-oped
and clicks went dead. Grow the whole history window via
loadOlderSessions instead, and only when the loaded tasks cannot fill
the next page. Addresses Greptile review on #13528.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* Auto-fill the Tasks page instead of fetching once per show-more click

A single 50-session window growth can consist entirely of pinned or
scheduled sessions, leaving a show-more click with no visible Tasks
progress. Replace the one-shot fetch with a page-fill effect that keeps
growing the history window until the requested Tasks page fills or
history runs out. Addresses the follow-up Greptile review on #13528.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* Halt page-fill retries after a failed history fetch

A failed fetch leaves the task count and has-more state unchanged,
which are exactly the conditions the page-fill effect fires on, so one
failing request would retry and re-toast forever. Halt the effect after
a failure and let the next explicit show-more click retry. Addresses
the third Greptile review on #13528.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
This commit is contained in:
Saoud Rizwan
2026-08-24 22:51:00 -07:00
committed by GitHub
parent 8f69880ac4
commit 4f5f238407
7 changed files with 449 additions and 124 deletions
@@ -1434,7 +1434,7 @@ export async function handleCommand(
const result = await backend.updateSession({ sessionId, metadata: merged });
if (!result.updated) throw new Error(`Session ${sessionId} not found`);
// Annotating a session is not session activity. updateSession stamps
// updated_at, which clients sort and label rows by, so a favorite would
// updated_at, which clients sort and label rows by, so a pin would
// otherwise make an old session look like it just ran.
if (existing?.updatedAt) {
store.run("UPDATE sessions SET updated_at = ? WHERE session_id = ?", [
@@ -47,13 +47,14 @@ function makeSessionHistory(
loadOlderSessions?: ReturnType<typeof vi.fn>;
mayHaveMoreSessions?: boolean;
hasLoadedHistory?: boolean;
isLoadingMore?: boolean;
} = {},
): UseSessionHistoryResult {
return {
deleteThread: vi.fn(),
forkThread: vi.fn(),
hasLoadedHistory: options.hasLoadedHistory ?? true,
isLoadingMore: false,
isLoadingMore: options.isLoadingMore ?? false,
loadAllSessions: vi.fn(async () => true),
loadOlderSessions: options.loadOlderSessions ?? vi.fn(),
loadMoreSessions,
@@ -178,7 +179,7 @@ afterEach(async () => {
});
describe("AgentSidebar session organization", () => {
it("filters scheduled sessions without changing their titles", async () => {
it("groups scheduled sessions into a collapsible Scheduled section", async () => {
const scheduled = {
...makeThread("scheduled", 1),
source: "core",
@@ -203,23 +204,186 @@ describe("AgentSidebar session organization", () => {
});
expect(sessionIsVisible("scheduled session 1")).toBe(true);
expect(container.textContent).not.toContain("(schedule)");
expect(sessionIsVisible("regular session 1")).toBe(true);
const scheduledHeader = buttonWithText("Scheduled");
const tasksHeader = buttonWithText("Tasks");
expect(scheduledHeader.getAttribute("aria-expanded")).toBe("true");
expect(tasksHeader.getAttribute("aria-expanded")).toBe("true");
// The scheduled and pinned categories replaced the old filter options.
await click(
container.querySelector('[aria-label="Filter sessions"]') as Element,
);
expect(document.body.textContent).not.toContain("Recent");
const schedulesOption = await vi.waitFor(() => {
const option = [
...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]'),
].find((candidate) => candidate.textContent?.includes("Schedules"));
expect(option).toBeDefined();
return option as HTMLElement;
await vi.waitFor(() => {
expect(
document.querySelectorAll('[role="menuitemradio"]').length,
).toBeGreaterThan(0);
});
await click(schedulesOption);
const filterOptionLabels = [
...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]'),
].map((option) => option.textContent);
expect(filterOptionLabels).not.toContain("Schedules");
expect(filterOptionLabels).not.toContain("Favorites");
expect(sessionIsVisible("scheduled session 1")).toBe(true);
expect(sessionIsVisible("regular session 1")).toBe(false);
await click(scheduledHeader);
expect(sessionIsVisible("scheduled session 1")).toBe(false);
expect(sessionIsVisible("regular session 1")).toBe(true);
});
it("shows pinned sessions in a Pinned section ahead of Tasks", async () => {
const pinned = { ...makeThread("pinned", 1), pinned: true };
const regular = makeThread("regular", 1);
await act(async () => {
root.render(
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([regular, pinned], vi.fn())}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>,
);
});
expect(sessionIsVisible("pinned session 1")).toBe(true);
expect(sessionIsVisible("regular session 1")).toBe(true);
expect(container.querySelector('[aria-label="Pinned"]')).not.toBeNull();
const pinnedHeader = buttonWithText("Pinned");
const tasksHeader = buttonWithText("Tasks");
expect(
pinnedHeader.compareDocumentPosition(tasksHeader) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
await click(pinnedHeader);
expect(sessionIsVisible("pinned session 1")).toBe(false);
expect(sessionIsVisible("regular session 1")).toBe(true);
});
it("keeps growing history when a fetch adds no tasks and more remain", async () => {
const scheduled = Array.from({ length: 8 }, (_, index) => ({
...makeThread("cron", index + 1),
isScheduled: true,
}));
const tasks = Array.from({ length: 5 }, (_, index) =>
makeThread("plain", index + 1),
);
const loadOlderSessions = vi.fn(async () => true);
const renderSidebar = async (isLoadingMore: boolean) => {
await act(async () => {
root.render(
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory(
[...scheduled, ...tasks],
vi.fn(),
{
isLoadingMore,
loadOlderSessions,
mayHaveMoreSessions: true,
},
)}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>,
);
});
};
await renderSidebar(false);
expect(loadOlderSessions).not.toHaveBeenCalled();
// Five loaded tasks cannot fill the requested page of 20, so the
// page-fill effect grows the history window.
await click(buttonWithText("Show more"));
expect(loadOlderSessions).toHaveBeenCalledOnce();
// Simulate that fetch settling without adding any tasks (the batch was
// all scheduled sessions): with more history remaining, the effect
// asks again instead of leaving the click without visible progress.
await renderSidebar(true);
await renderSidebar(false);
expect(loadOlderSessions).toHaveBeenCalledTimes(2);
});
it("halts page-fill retries after a failed fetch until the next click", async () => {
const tasks = Array.from({ length: 5 }, (_, index) =>
makeThread("plain", index + 1),
);
const loadOlderSessions = vi.fn(async () => false);
const renderSidebar = async (isLoadingMore: boolean) => {
await act(async () => {
root.render(
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory(tasks, vi.fn(), {
isLoadingMore,
loadOlderSessions,
mayHaveMoreSessions: true,
})}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>,
);
});
};
await renderSidebar(false);
await click(buttonWithText("Show more"));
expect(loadOlderSessions).toHaveBeenCalledOnce();
// The failed fetch settles with nothing changed; retrying automatically
// would loop the same failing request and re-toast the error forever.
await renderSidebar(true);
await renderSidebar(false);
expect(loadOlderSessions).toHaveBeenCalledOnce();
// An explicit click clears the halt and retries.
await click(buttonWithText("Show more"));
expect(loadOlderSessions).toHaveBeenCalledTimes(2);
});
it("keeps a flat session list when nothing is pinned or scheduled", async () => {
await act(async () => {
root.render(
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory(
[makeThread("plain", 1)],
vi.fn(),
)}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>,
);
});
expect(sessionIsVisible("plain session 1")).toBe(true);
expect(container.textContent).not.toContain("Pinned");
expect(container.textContent).not.toContain("Scheduled");
expect(container.textContent).not.toContain("Tasks");
});
it("defaults to all sources and filters by the selected client source", async () => {
@@ -382,9 +546,18 @@ describe("AgentSidebar session organization", () => {
expect(sessionIsVisible("alpha session 11")).toBe(false);
expect(sessionIsVisible("beta session 1")).toBe(false);
// The first page grows purely from already-loaded sessions (24 loaded,
// 20 requested), so no history fetch is needed.
await click(buttonWithText("Show more"));
expect(sessionIsVisible("alpha session 11")).toBe(true);
expect(loadMoreSessions).toHaveBeenCalledWith(20);
expect(loadMoreSessions).not.toHaveBeenCalled();
expect(loadOlderSessions).not.toHaveBeenCalled();
// The next page (30) exceeds the 24 loaded sessions, so the whole
// history window grows.
await click(buttonWithText("Show more"));
expect(sessionIsVisible("beta session 12")).toBe(true);
expect(loadOlderSessions).toHaveBeenCalledOnce();
await click(
container.querySelector('[aria-label="Sort sessions: Time"]') as Element,
@@ -413,7 +586,7 @@ describe("AgentSidebar session organization", () => {
expect(sessionIsVisible("beta session 11")).toBe(false);
await click(buttonWithText("Load older projects"));
expect(loadOlderSessions).toHaveBeenCalledOnce();
expect(loadOlderSessions).toHaveBeenCalledTimes(2);
});
it("shows the signed-in account and active organization in the footer", async () => {
@@ -17,13 +17,13 @@ import {
Loader2,
PanelLeftOpen,
Pencil,
Pin,
Plug,
Plus,
Radio,
Search,
Settings,
SlidersHorizontal,
Star,
Store,
Trash2,
Wrench,
@@ -114,9 +114,10 @@ import { cn } from "@/lib/utils";
type Thread = SessionThread;
type AppView = "chat" | "sessions" | "settings";
const filterOptions = ["All", "Running", "Schedules", "Favorites"] as const;
const filterOptions = ["All", "Running"] as const;
type FilterOption = (typeof filterOptions)[number];
type SidebarSortMode = "time" | "project";
type SessionCategory = "pinned" | "scheduled" | "tasks";
type DesktopProcessContext = {
appVersion?: unknown;
hub?: {
@@ -257,7 +258,6 @@ export function AgentSidebar({
isLoadingMore,
loadAllSessions,
loadOlderSessions,
loadMoreSessions,
mayHaveMoreSessions,
openThread: openHistoryThread,
pendingAction,
@@ -274,6 +274,12 @@ export function AgentSidebar({
const [showMoreCount, setShowMoreCount] = useState(
INITIAL_VISIBLE_THREAD_COUNT,
);
const [scheduledVisibleCount, setScheduledVisibleCount] = useState(
INITIAL_VISIBLE_THREAD_COUNT,
);
const [collapsedSections, setCollapsedSections] = useState<
Set<SessionCategory>
>(() => new Set());
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState("");
const [deleteConfirmThread, setDeleteConfirmThread] = useState<Thread | null>(
@@ -329,16 +335,10 @@ export function AgentSidebar({
const sourceOptions = useMemo(() => getSessionSources(threads), [threads]);
const filteredThreads = useMemo(() => {
const filtered = filterSessionsBySource(threads, sourceFilter);
switch (filter) {
case "Running":
return filtered.filter((t) => t.status === "running");
case "Schedules":
return filtered.filter((t) => t.isScheduled);
case "Favorites":
return filtered.filter((t) => t.pinned);
default:
return filtered;
if (filter === "Running") {
return filtered.filter((t) => t.status === "running");
}
return filtered;
}, [filter, sourceFilter, threads]);
const closeMobileSidebar = useCallback(() => {
if (isMobile) setOpenMobile(false);
@@ -418,7 +418,7 @@ export function AgentSidebar({
[forkHistoryThread],
);
const toggleFavorite = useCallback(
const togglePinned = useCallback(
async (thread: Thread) => {
await setThreadPinned(thread.id, !thread.pinned);
},
@@ -441,25 +441,75 @@ export function AgentSidebar({
() => filteredThreads.filter((t) => t.pinned),
[filteredThreads],
);
const sessionThreads = useMemo(
() => filteredThreads.filter((t) => !t.pinned),
const scheduledThreads = useMemo(
() => filteredThreads.filter((t) => !t.pinned && t.isScheduled),
[filteredThreads],
);
const displayedThreads = useMemo(
() =>
filter === "All"
? [...pinnedThreads, ...sessionThreads.slice(0, showMoreCount)]
: [...pinnedThreads, ...sessionThreads].slice(0, showMoreCount),
[filter, pinnedThreads, sessionThreads, showMoreCount],
const taskThreads = useMemo(
() => filteredThreads.filter((t) => !t.pinned && !t.isScheduled),
[filteredThreads],
);
// Category headers only appear once there is something to categorize;
// a lone "Tasks" header over the whole list would be noise.
const showCategorySections =
pinnedThreads.length > 0 || scheduledThreads.length > 0;
const showTimeShowMore =
sessionThreads.length > showMoreCount ||
taskThreads.length > showMoreCount ||
(filter === "All" && mayHaveMoreSessions);
// A failed fetch leaves the task count and has-more state unchanged, which
// are exactly the conditions the page-fill effect fires on; without this
// halt it would retry a failing request (and re-toast the error) forever.
// The next explicit "Show more" click clears the halt to retry.
const pageFillFailedRef = useRef(false);
// A "Show more" click can outpace the loaded history: showMoreCount counts
// only Tasks rows while the backend limit counts all sessions, and a
// fetched batch can consist entirely of pinned or scheduled sessions. Keep
// growing the history window until the requested Tasks page fills or
// history runs out, so every click makes visible progress. The
// isLoadingMore dependency retriggers the check after each fetch settles.
useEffect(() => {
if (
sortMode !== "time" ||
filter !== "All" ||
isLoadingMore ||
!mayHaveMoreSessions ||
showMoreCount <= INITIAL_VISIBLE_THREAD_COUNT ||
taskThreads.length >= showMoreCount ||
pageFillFailedRef.current
) {
return;
}
void loadOlderSessions().then((loaded) => {
if (!loaded) {
pageFillFailedRef.current = true;
}
});
}, [
filter,
isLoadingMore,
loadOlderSessions,
mayHaveMoreSessions,
showMoreCount,
sortMode,
taskThreads.length,
]);
const projectGroups = useMemo(
() => groupThreadsByProject([...pinnedThreads, ...sessionThreads]),
[pinnedThreads, sessionThreads],
() =>
groupThreadsByProject([
...pinnedThreads,
...filteredThreads.filter((t) => !t.pinned),
]),
[filteredThreads, pinnedThreads],
);
const toggleSection = useCallback((section: SessionCategory) => {
setCollapsedSections((current) => {
const next = new Set(current);
if (next.has(section)) next.delete(section);
else next.add(section);
return next;
});
}, []);
const toggleProject = useCallback((project: string) => {
setCollapsedProjects((current) => {
const next = new Set(current);
@@ -490,11 +540,12 @@ export function AgentSidebar({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuLabel>Session type</DropdownMenuLabel>
<DropdownMenuLabel>Status</DropdownMenuLabel>
<DropdownMenuRadioGroup
onValueChange={(value) => {
setFilter(value as FilterOption);
setShowMoreCount(INITIAL_VISIBLE_THREAD_COUNT);
setScheduledVisibleCount(INITIAL_VISIBLE_THREAD_COUNT);
setProjectVisibleCounts({});
}}
value={filter}
@@ -513,6 +564,7 @@ export function AgentSidebar({
onValueChange={(value) => {
setSourceFilter(value);
setShowMoreCount(INITIAL_VISIBLE_THREAD_COUNT);
setScheduledVisibleCount(INITIAL_VISIBLE_THREAD_COUNT);
setProjectVisibleCounts({});
}}
value={sourceFilter}
@@ -578,7 +630,7 @@ export function AgentSidebar({
onEditTitleChange={setEditingTitle}
onFork={() => void forkThread(thread)}
onRename={() => startRenameThread(thread)}
onToggleFavorite={() => void toggleFavorite(thread)}
onTogglePin={() => void togglePinned(thread)}
pendingAction={
pendingAction?.sessionId === thread.id ? pendingAction.action : null
}
@@ -586,6 +638,37 @@ export function AgentSidebar({
unread={unreadSessionIds.has(thread.id)}
/>
);
const timeShowMoreButton = (
<Button
// `pl-0!`: the default button size adds
// `has-[>svg]:px-3`, and that modifier beats a plain
// `pl-0` on specificity, so the icon child was
// re-indenting the row.
className="pl-0!"
disabled={isLoadingMore}
onClick={() => {
// Raising the page size is enough: the page-fill effect fetches
// older history whenever loaded tasks cannot fill the page. An
// explicit click also retries after a failed fetch halted it.
pageFillFailedRef.current = false;
setShowMoreCount(showMoreCount + INITIAL_VISIBLE_THREAD_COUNT);
}}
type="button"
variant="sidebarText"
>
{isLoadingMore ? (
<>
<Loader2 className="size-3 animate-spin" />
Loading...
</>
) : (
<div className="ml-2 flex items-center gap-1">
Show more
<ChevronDown className="size-3" />
</div>
)}
</Button>
);
return (
<>
@@ -831,41 +914,101 @@ export function AgentSidebar({
</div>
) : (
<>
{sortMode === "time"
? displayedThreads.map(threadItem)
: projectGroups.map((project) => {
const visibleCount =
projectVisibleCounts[project.id] ??
INITIAL_VISIBLE_THREAD_COUNT;
return (
<ProjectSection
collapsed={collapsedProjects.has(project.id)}
key={project.id}
label={project.label}
onToggle={() => toggleProject(project.id)}
{sortMode === "time" ? (
showCategorySections ? (
<>
{pinnedThreads.length > 0 ? (
<CategorySection
collapsed={collapsedSections.has("pinned")}
count={pinnedThreads.length}
label="Pinned"
onToggle={() => toggleSection("pinned")}
>
{project.threads
.slice(0, visibleCount)
{pinnedThreads.map(threadItem)}
</CategorySection>
) : null}
{scheduledThreads.length > 0 ? (
<CategorySection
collapsed={collapsedSections.has("scheduled")}
count={scheduledThreads.length}
label="Scheduled"
onToggle={() => toggleSection("scheduled")}
>
{scheduledThreads
.slice(0, scheduledVisibleCount)
.map(threadItem)}
{project.threads.length > visibleCount ? (
{scheduledThreads.length >
scheduledVisibleCount ? (
<Button
className="pl-2!"
className="pl-0!"
onClick={() =>
showMoreForProject(project.id)
setScheduledVisibleCount(
(current) =>
current +
INITIAL_VISIBLE_THREAD_COUNT,
)
}
type="button"
variant="sidebarText"
>
Show more in {project.label}
<ChevronDown className="size-3" />
<div className="ml-2 flex items-center gap-1">
Show more
<ChevronDown className="size-3" />
</div>
</Button>
) : null}
</ProjectSection>
);
})}
</CategorySection>
) : null}
{taskThreads.length > 0 || showTimeShowMore ? (
<CategorySection
collapsed={collapsedSections.has("tasks")}
count={taskThreads.length}
label="Tasks"
onToggle={() => toggleSection("tasks")}
>
{taskThreads
.slice(0, showMoreCount)
.map(threadItem)}
{showTimeShowMore ? timeShowMoreButton : null}
</CategorySection>
) : null}
</>
) : (
taskThreads.slice(0, showMoreCount).map(threadItem)
)
) : (
projectGroups.map((project) => {
const visibleCount =
projectVisibleCounts[project.id] ??
INITIAL_VISIBLE_THREAD_COUNT;
return (
<ProjectSection
collapsed={collapsedProjects.has(project.id)}
key={project.id}
label={project.label}
onToggle={() => toggleProject(project.id)}
>
{project.threads
.slice(0, visibleCount)
.map(threadItem)}
{project.threads.length > visibleCount ? (
<Button
className="pl-2!"
onClick={() => showMoreForProject(project.id)}
type="button"
variant="sidebarText"
>
Show more in {project.label}
<ChevronDown className="size-3" />
</Button>
) : null}
</ProjectSection>
);
})
)}
{(sortMode === "time"
? displayedThreads.length === 0
? filteredThreads.length === 0
: projectGroups.length === 0) && (
<div className="px-2 py-4 text-xs text-muted-foreground">
No sessions found in history.
@@ -873,36 +1016,10 @@ export function AgentSidebar({
)}
</>
)}
{sortMode === "time" && showTimeShowMore && (
<Button
// `pl-0!`: the default button size adds
// `has-[>svg]:px-3`, and that modifier beats a plain
// `pl-0` on specificity, so the icon child was
// re-indenting the row.
className="pl-0!"
disabled={isLoadingMore}
onClick={() => {
const nextCount =
showMoreCount + INITIAL_VISIBLE_THREAD_COUNT;
setShowMoreCount(nextCount);
void loadMoreSessions(nextCount);
}}
type="button"
variant="sidebarText"
>
{isLoadingMore ? (
<>
<Loader2 className="size-3 animate-spin" />
Loading...
</>
) : (
<div className="ml-2 flex items-center gap-1">
Show more
<ChevronDown className="size-3" />
</div>
)}
</Button>
)}
{sortMode === "time" &&
!showCategorySections &&
showTimeShowMore &&
timeShowMoreButton}
{sortMode === "project" &&
filter === "All" &&
mayHaveMoreSessions && (
@@ -1083,6 +1200,44 @@ export function AgentSidebar({
);
}
function CategorySection({
label,
count,
collapsed,
onToggle,
children,
}: {
label: string;
count: number;
collapsed: boolean;
onToggle: () => void;
children: ReactNode;
}) {
return (
<div className="mb-1 min-w-0">
<button
aria-expanded={!collapsed}
className="flex h-7 w-full min-w-0 items-center gap-1 rounded-md px-1 text-left text-xs font-medium text-muted-foreground hover:bg-surface-hover-lighter hover:text-sidebar-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
onClick={onToggle}
title={label}
type="button"
>
<ChevronDown
className={cn(
"size-3 shrink-0 transition-transform",
collapsed && "-rotate-90",
)}
/>
<span className="block min-w-0 truncate">{label}</span>
<span className="ml-auto pr-1 text-[10px] tabular-nums">{count}</span>
</button>
{!collapsed ? (
<div className="flex min-w-0 flex-col gap-0.5">{children}</div>
) : null}
</div>
);
}
function ProjectSection({
label,
collapsed,
@@ -1126,7 +1281,7 @@ function ThreadItem({
onCommitRename,
onEditTitleChange,
onRename,
onToggleFavorite,
onTogglePin,
onFork,
onDelete,
pendingAction,
@@ -1141,7 +1296,7 @@ function ThreadItem({
onCommitRename: () => void;
onEditTitleChange: (title: string) => void;
onRename: () => void;
onToggleFavorite: () => void;
onTogglePin: () => void;
onFork: () => void;
onDelete: () => void;
pendingAction: "rename" | "fork" | "delete" | null;
@@ -1204,10 +1359,7 @@ function ThreadItem({
</span>
<span className="flex shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground">
{thread.pinned ? (
<Star
aria-label="Favorited"
className="size-3 fill-current"
/>
<Pin aria-label="Pinned" className="size-3 fill-current" />
) : statusDotClass ? (
<span
aria-hidden="true"
@@ -1247,12 +1399,12 @@ function ThreadItem({
</HoverCardContent>
</HoverCard>
<SessionContextMenuContent
favorited={Boolean(thread.pinned)}
onDelete={onDelete}
onFork={onFork}
onRename={onRename}
onToggleFavorite={onToggleFavorite}
onTogglePin={onTogglePin}
pendingAction={pendingAction}
pinned={Boolean(thread.pinned)}
/>
</ContextMenu>
);
@@ -1339,16 +1491,16 @@ function EditableSessionTitle({
}
function SessionContextMenuContent({
favorited,
pinned,
onRename,
onToggleFavorite,
onTogglePin,
onFork,
onDelete,
pendingAction,
}: {
favorited: boolean;
pinned: boolean;
onRename: () => void;
onToggleFavorite: () => void;
onTogglePin: () => void;
onFork: () => void;
onDelete: () => void;
pendingAction: "rename" | "fork" | "delete" | null;
@@ -1356,9 +1508,9 @@ function SessionContextMenuContent({
const pending = pendingAction !== null;
return (
<ContextMenuContent className="w-40">
<ContextMenuItem disabled={pending} onSelect={onToggleFavorite}>
<Star className={cn("size-4", favorited && "fill-current")} />
{favorited ? "Unfavorite" : "Favorite"}
<ContextMenuItem disabled={pending} onSelect={onTogglePin}>
<Pin className={cn("size-4", pinned && "fill-current")} />
{pinned ? "Unpin" : "Pin"}
</ContextMenuItem>
<ContextMenuItem disabled={pending} onSelect={onRename}>
{pendingAction === "rename" ? (
@@ -152,19 +152,19 @@ describe("SessionsView table", () => {
expect(row?.parentElement?.className).not.toContain("min-h-14");
});
it("marks favorited sessions with a star", async () => {
it("marks pinned sessions with a pin icon", async () => {
const plain = renderView();
await plain.render();
expect(container.querySelector('[aria-label="Favorited"]')).toBeNull();
expect(container.querySelector('[aria-label="Pinned"]')).toBeNull();
await act(async () => root.unmount());
root = createRoot(container);
const favorited = renderView({
const pinned = renderView({
threads: [{ ...thread, pinned: true }],
});
await favorited.render();
expect(container.querySelector('[aria-label="Favorited"]')).not.toBeNull();
await pinned.render();
expect(container.querySelector('[aria-label="Pinned"]')).not.toBeNull();
});
it("opens a session on click but not while text is selected", async () => {
@@ -13,8 +13,8 @@ import {
Loader2,
MoreHorizontal,
Pencil,
Pin,
Search,
Star,
Trash2,
X,
} from "lucide-react";
@@ -131,7 +131,7 @@ function sessionFilterDetails(
const workspacePath = session?.workspaceRoot || session?.cwd || "";
const workspace = workspacePath ? basenamePath(workspacePath) : "";
return [
thread.pinned ? "favorite:yes" : undefined,
thread.pinned ? "pinned:yes" : undefined,
workspace ? `workspace:${workspace}` : undefined,
thread.status ? `status:${thread.status}` : undefined,
thread.provider ? `provider:${thread.provider}` : undefined,
@@ -572,8 +572,8 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
/>
<span className="truncate">{thread.title}</span>
{thread.pinned ? (
<Star
aria-label="Favorited"
<Pin
aria-label="Pinned"
className="size-3.5 shrink-0 fill-current text-muted-foreground"
/>
) : null}
@@ -626,13 +626,13 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
)
}
>
<Star
<Pin
className={cn(
"size-4",
thread.pinned && "fill-current",
)}
/>
{thread.pinned ? "Unfavorite" : "Favorite"}
{thread.pinned ? "Unpin" : "Pin"}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => startRename(thread)}>
<Pencil className="size-4" />
@@ -1209,7 +1209,7 @@ export function useSessionHistory({
);
};
// Favoriting is a single click, so apply it locally first and roll back
// Pinning is a single click, so apply it locally first and roll back
// if the write fails rather than blocking the row on a round trip.
applyPinned(pinned);
try {
@@ -1228,7 +1228,7 @@ export function useSessionHistory({
applyPinned(!pinned);
toast({
variant: "destructive",
title: pinned ? "Favorite failed" : "Unfavorite failed",
title: pinned ? "Pin failed" : "Unpin failed",
description:
error instanceof Error
? error.message
@@ -8,7 +8,7 @@ export type SessionHistoryStatus =
export type SessionMetadata = {
title?: string;
/**
* Favorited sessions. Stored in session metadata rather than desktop-local
* Pinned sessions. Stored in session metadata rather than desktop-local
* state so every client reading the session sees the same flag.
*/
pinned?: boolean;