mirror of
https://github.com/cline/cline.git
synced 2026-08-30 17:20:20 +08:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user