- {loading && totalCount === 0 ? (
+ {loading && bothEmpty ? (
- ) : !firstVisible ? (
+ ) : bothEmpty ? (
- {totalCount === 0
- ? `Nothing yet. Start a ${jamieName} chat and it lands here.`
- : "No chats or plans match."}
+ {`Nothing yet. Start a ${jamieName} chat and it lands here.`}
) : (
- groups.flatMap((group) => [
-
- {group.label}
- ,
- ...group.rows.map(({ item, depth, parentKey, childCount, latestAt }, i) => {
- if (parentKey && !expandedKeys.has(parentKey)) return null;
- const { Icon, className: kindClass } = KIND[item.kind];
- const focused = item.key === focusedKey;
- const cursor = item.key === cursorKey;
- const collapsible = (childCount ?? 0) > 0;
- const collapsed = collapsible && !expandedKeys.has(item.key);
- const lastChild = group.rows[i + 1]?.parentKey !== parentKey;
- const meta = [item.workspaceName, item.sinceYou].filter(Boolean).join(" · ");
- return (
- onOpen(item)}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- onOpen(item);
- }
- }}
- aria-current={focused ? "true" : undefined}
- style={{ paddingLeft: depth > 0 ? PLAN_ROW_PAD : CHAT_ROW_PAD }}
- className={cn(
- "relative flex w-full cursor-pointer items-start gap-2 border-b py-2.5 pr-3 text-left outline-none transition-colors",
- focused ? "bg-muted" : "hover:bg-muted/60",
- cursor && "ring-1 ring-inset ring-ring",
- )}
+ <>
+ {!firstVisible && totalCount > 0 ? (
+ No chats or plans match.
+ ) : firstVisible ? (
+ groups.flatMap((group) => [
+
- {/* The tree: a stem from an open chat's icon, a guide down its plans, a tick into each. */}
- {collapsible && !collapsed && (
- ,
+ ...group.rows.map(({ item, depth, parentKey, childCount, latestAt }, i) => (
+
+ )),
+ ])
+ ) : null}
+ {firstVisible && remaining > 0 && (
+
+ Show {Math.min(remaining, CONTROL_PANEL_PAGE)} more
+
+ )}
+
+
+
+ {archivedExpanded ? (
+
+ ) : (
+
+ )}
+ Archive
+ {archivedChatCount > 0 && (
+ {archivedChatCount}
+ )}
+
+ {archivedExpanded &&
+ (archivedChatCount === 0 ? (
+ No archived chats
+ ) : (
+ archivedRows.map(({ item, depth, parentKey, childCount, latestAt }, i) => (
+
- )}
- {depth > 0 && (
- <>
-
-
- >
- )}
- {collapsible ? (
- {
- e.stopPropagation();
- onToggleExpanded(item.key);
- }}
- className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
- >
- {collapsed ? : }
-
- ) : (
- depth === 0 &&
- )}
- {/* Every chat row reserves the same slot for the count, so titles line up. */}
- {depth === 0 && (
-
- {collapsed ? childCount : ""}
-
- )}
- 0 ? "h-3.5 w-3.5" : "h-4 w-4", kindClass)} />
-
-
0 ? "text-[13px]" : "text-sm",
- (item.unread || focused) && "font-semibold",
- )}
- >
- {item.title}
-
-
{meta}
-
-
-
- {formatAge(Date.now() - Date.parse(latestAt))}
-
-
-
-
- );
- }),
- ])
- )}
- {firstVisible && remaining > 0 && (
-
- Show {Math.min(remaining, CONTROL_PANEL_PAGE)} more
-
+ ))
+ ))}
+
+ >
)}
- {firstVisible && (
+ {(firstVisible || archivedChatCount > 0) && (
(() => focusFromParams(searchParams));
const [cursorKey, setCursorKey] = useState(null);
// Chats start collapsed: the row is the chat, its plans open on demand.
const [expandedKeys, setExpandedKeys] = useState>(() => new Set());
+ const [archivedExpanded, setArchivedExpanded] = useState(false);
const toggleExpanded = useCallback((key: string) => {
setExpandedKeys((prev) => {
const next = new Set(prev);
@@ -66,6 +77,7 @@ export function useControlPanel(githubLogin: string, enabled: boolean): ControlP
return next;
});
}, []);
+ const toggleArchived = useCallback(() => setArchivedExpanded((open) => !open), []);
// Leaving the control panel puts the chat back on stage, so coming
// back starts clean (the URL's `?plan=`/`?task=` go with it).
@@ -136,30 +148,33 @@ export function useControlPanel(githubLogin: string, enabled: boolean): ControlP
if (prev.server === null && activeServerConversationId) void refetch();
}, [enabled, activeLocalId, activeServerConversationId, changeFocus, refetch]);
- // The chat on stage is always in the list, and the list already knows
+ // The chat on stage is always in a list, and the list already knows
// what the store knows about it: a fresh chat has no server row until
// its first turn lands, so until the server lists it the row is built
// from the store; once listed, the server's row is brought up to date
// (read, working while Jamie replies, a message the fetch missed).
+ // An archived on-stage chat stays in Archive — never re-prepended into
+ // Active after a poll drops it from `items`.
const chatOnStage = focus.kind === "chat";
- const displayItems = useMemo(() => {
- if (!activeChat || !activeChatKey) return items;
- const listed = items.some((item) => item.key === activeChatKey);
- if (listed) {
- return items.map((item) => (item.key === activeChatKey ? overlayActiveChat(item, activeChat) : item));
- }
- if (!activeChat.hasMessages && !chatOnStage) return items;
- // The server titles a chat from its first user message.
- const conv = useCanvasChatStore.getState().conversations[activeChat.localId];
- const title = activeChat.hasMessages && conv ? generateTitle(conv.messages) : "New chat";
- return [activeChatItem(activeChat, newChatStartedAtRef.current, title), ...items];
- }, [items, activeChat, activeChatKey, chatOnStage]);
+ const { displayItems, displayArchivedItems } = useMemo(() => {
+ const conv = activeChat ? useCanvasChatStore.getState().conversations[activeChat.localId] : undefined;
+ const title = activeChat?.hasMessages && conv ? generateTitle(conv.messages) : "New chat";
+ return resolveControlPanelLists(items, archivedItems, activeChat, {
+ chatOnStage,
+ startedAt: newChatStartedAtRef.current,
+ titleForNew: title,
+ });
+ }, [items, archivedItems, activeChat, chatOnStage]);
const focusedKey = focus.kind === "chat" ? activeChatKey : `${focus.kind}:${focus.id}`;
- const focusedItem = useMemo(
- () => (focusedKey ? (items.find((i) => i.key === focusedKey) ?? null) : null),
- [items, focusedKey],
- );
+ const focusedItem = useMemo(() => {
+ if (!focusedKey) return null;
+ return (
+ displayItems.find((i) => i.key === focusedKey) ??
+ displayArchivedItems.find((i) => i.key === focusedKey) ??
+ null
+ );
+ }, [displayItems, displayArchivedItems, focusedKey]);
const openItem = useCallback(
async (item: ControlPanelItem) => {
@@ -184,6 +199,7 @@ export function useControlPanel(githubLogin: string, enabled: boolean): ControlP
() => buildControlPanelGroups(displayItems, (item) => matchesControlPanelQuery(item, query)),
[displayItems, query],
);
+ const archivedRows = useMemo(() => buildArchivedRows(displayArchivedItems), [displayArchivedItems]);
// A search is asking to see matches, so every chat with a match opens;
// otherwise what the user opened holds.
const effectiveExpanded = useMemo(
@@ -194,13 +210,11 @@ export function useControlPanel(githubLogin: string, enabled: boolean): ControlP
[query, groups, expandedKeys],
);
- // Rows the keyboard can land on: collapsed chats hide their plans.
+ // Rows the keyboard can land on: collapsed chats hide their plans;
+ // a collapsed Archive is skipped entirely.
const visible = useMemo(
- () =>
- groups.flatMap((g) =>
- g.rows.filter((r) => !(r.parentKey && !effectiveExpanded.has(r.parentKey))).map((r) => r.item),
- ),
- [groups, effectiveExpanded],
+ () => visibleControlPanelItems(groups, archivedRows, effectiveExpanded, archivedExpanded),
+ [groups, archivedRows, effectiveExpanded, archivedExpanded],
);
useEffect(() => {
@@ -224,6 +238,21 @@ export function useControlPanel(githubLogin: string, enabled: boolean): ControlP
);
const onOpen = useCallback((item: ControlPanelItem) => void openItem(item), [openItem]);
+ const onArchive = useCallback(
+ (item: ControlPanelItem) => {
+ if (item.kind !== "chat") return;
+ setArchivedExpanded(true);
+ void archiveConversation(item.id);
+ },
+ [archiveConversation],
+ );
+ const onRestore = useCallback(
+ (item: ControlPanelItem) => {
+ if (item.kind !== "chat") return;
+ void restoreConversation(item.id);
+ },
+ [restoreConversation],
+ );
useEffect(() => {
if (!enabled) return;
@@ -271,6 +300,11 @@ export function useControlPanel(githubLogin: string, enabled: boolean): ControlP
onOpen,
remaining,
onShowMore: showMore,
+ archivedRows,
+ archivedExpanded,
+ onToggleArchived: toggleArchived,
+ onArchive,
+ onRestore,
},
stage: {
focus,
diff --git a/src/app/org/[githubLogin]/_components/control-panel/useControlPanelItems.ts b/src/app/org/[githubLogin]/_components/control-panel/useControlPanelItems.ts
index 09fb010c49..56ce7a2505 100644
--- a/src/app/org/[githubLogin]/_components/control-panel/useControlPanelItems.ts
+++ b/src/app/org/[githubLogin]/_components/control-panel/useControlPanelItems.ts
@@ -1,6 +1,8 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
+import { toast } from "sonner";
+import { moveChatToActive, moveChatToArchive } from "@/services/orgs/control-panel-state";
import type { ControlPanelItem, ControlPanelResponse } from "@/types/control-panel";
const POLL_MS = 30_000;
@@ -13,37 +15,53 @@ export const CONTROL_PANEL_PAGE = 30;
* grows; every refetch asks for everything shown so far. While disabled
* (the canvas is showing) nothing is fetched and the last list is kept,
* so coming back shows it at once.
+ *
+ * Archive rides the same fetch (`archivedItems` on the response). There
+ * is no second poll and no `?archived=1`. `remaining` / `showMore` stay
+ * wired to active chats only.
*/
export function useControlPanelItems(
githubLogin: string,
enabled: boolean,
): {
items: ControlPanelItem[];
+ archivedItems: ControlPanelItem[];
/** Chats the user has in this org beyond the ones listed. */
remaining: number;
loading: boolean;
refetch: () => Promise;
showMore: () => void;
+ archiveConversation: (id: string) => Promise;
+ restoreConversation: (id: string) => Promise;
} {
const [items, setItems] = useState([]);
+ const [archivedItems, setArchivedItems] = useState([]);
const [remaining, setRemaining] = useState(0);
const [loading, setLoading] = useState(true);
const inFlightRef = useRef(false);
+ const genRef = useRef(0);
const limitRef = useRef(CONTROL_PANEL_PAGE);
+ const itemsRef = useRef(items);
+ const archivedItemsRef = useRef(archivedItems);
+ itemsRef.current = items;
+ archivedItemsRef.current = archivedItems;
const refetch = useCallback(async () => {
if (!githubLogin || !enabled || inFlightRef.current) return;
+ const gen = ++genRef.current;
inFlightRef.current = true;
try {
const res = await fetch(`/api/orgs/${githubLogin}/control-panel?limit=${limitRef.current}`);
if (!res.ok) return;
const data = (await res.json()) as ControlPanelResponse;
+ if (gen !== genRef.current) return;
setItems(Array.isArray(data.items) ? data.items : []);
+ setArchivedItems(Array.isArray(data.archivedItems) ? data.archivedItems : []);
setRemaining(Math.max(0, (data.chats?.total ?? 0) - (data.chats?.shown ?? 0)));
} catch {
// Keep the last good list; the next poll retries.
} finally {
- inFlightRef.current = false;
+ if (gen === genRef.current) inFlightRef.current = false;
setLoading(false);
}
}, [githubLogin, enabled]);
@@ -65,5 +83,49 @@ export function useControlPanelItems(
void refetch();
}, [refetch]);
- return { items, remaining, loading, refetch, showMore };
+ const postArchive = useCallback(
+ async (id: string, archived: boolean) => {
+ const prevItems = itemsRef.current;
+ const prevArchived = archivedItemsRef.current;
+ // Invalidate any in-flight poll so it cannot clobber the optimistic move.
+ genRef.current += 1;
+ inFlightRef.current = false;
+ const moved = archived
+ ? moveChatToArchive(prevItems, prevArchived, id, new Date().toISOString())
+ : moveChatToActive(prevItems, prevArchived, id);
+ setItems(moved.items);
+ setArchivedItems(moved.archivedItems);
+
+ try {
+ const res = await fetch(`/api/orgs/${githubLogin}/chat/conversations/${id}/archive`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ archived }),
+ });
+ if (!res.ok) throw new Error(archived ? "Failed to archive chat" : "Failed to restore chat");
+ await refetch();
+ } catch (error) {
+ setItems(prevItems);
+ setArchivedItems(prevArchived);
+ toast.error(archived ? "Failed to archive chat" : "Failed to restore chat", {
+ description: error instanceof Error ? error.message : "Unknown error",
+ });
+ }
+ },
+ [githubLogin, refetch],
+ );
+
+ const archiveConversation = useCallback((id: string) => postArchive(id, true), [postArchive]);
+ const restoreConversation = useCallback((id: string) => postArchive(id, false), [postArchive]);
+
+ return {
+ items,
+ archivedItems,
+ remaining,
+ loading,
+ refetch,
+ showMore,
+ archiveConversation,
+ restoreConversation,
+ };
}
diff --git a/src/services/orgs/control-panel-state.ts b/src/services/orgs/control-panel-state.ts
index d31b1e4325..c87d387e5f 100644
--- a/src/services/orgs/control-panel-state.ts
+++ b/src/services/orgs/control-panel-state.ts
@@ -204,6 +204,159 @@ function dayLabel(date: Date, now: Date): string {
});
}
+/**
+ * Pull a chat and the plans nested under it out of a flat item list.
+ * Used by the optimistic archive/restore move so nested plans follow
+ * the parent instead of hanging in the other collection.
+ */
+export function takeChatFamily(
+ items: ControlPanelItem[],
+ chatId: string,
+): { family: ControlPanelItem[]; rest: ControlPanelItem[] } {
+ const family: ControlPanelItem[] = [];
+ const rest: ControlPanelItem[] = [];
+ for (const item of items) {
+ if ((item.kind === "chat" && item.id === chatId) || (item.kind === "plan" && item.parentChatId === chatId)) {
+ family.push(item);
+ } else {
+ rest.push(item);
+ }
+ }
+ return { family, rest };
+}
+
+function orderChatFamily(family: ControlPanelItem[]): ControlPanelItem[] {
+ const chat = family.find((item) => item.kind === "chat");
+ const plans = family.filter((item) => item.kind === "plan");
+ return chat ? [chat, ...plans] : family;
+}
+
+/**
+ * Optimistic archive: drop the chat and its nested plans from Active
+ * and insert them at the top of Archive (newest `archivedAt` first).
+ */
+export function moveChatToArchive(
+ items: ControlPanelItem[],
+ archivedItems: ControlPanelItem[],
+ chatId: string,
+ archivedAt: string,
+): { items: ControlPanelItem[]; archivedItems: ControlPanelItem[] } {
+ const { family, rest } = takeChatFamily(items, chatId);
+ if (family.length === 0) return { items, archivedItems };
+ const stamped = family.map((item) => (item.kind === "chat" ? { ...item, archivedAt } : item));
+ const { rest: archivedRest } = takeChatFamily(archivedItems, chatId);
+ return { items: rest, archivedItems: [...orderChatFamily(stamped), ...archivedRest] };
+}
+
+/**
+ * Optimistic restore: drop the chat and its nested plans from Archive
+ * and insert them at the top of Active.
+ */
+export function moveChatToActive(
+ items: ControlPanelItem[],
+ archivedItems: ControlPanelItem[],
+ chatId: string,
+): { items: ControlPanelItem[]; archivedItems: ControlPanelItem[] } {
+ const { family, rest } = takeChatFamily(archivedItems, chatId);
+ if (family.length === 0) return { items, archivedItems };
+ const stamped = family.map((item) => (item.kind === "chat" ? { ...item, archivedAt: null } : item));
+ const { rest: itemsRest } = takeChatFamily(items, chatId);
+ return { items: [...orderChatFamily(stamped), ...itemsRest], archivedItems: rest };
+}
+
+/**
+ * Place the on-stage chat into Active or Archive without re-injecting an
+ * archived chat into the active list. Overlay live store data onto whichever
+ * row already holds it; only a brand-new chat (in neither list) is prepended
+ * into Active.
+ */
+export function resolveControlPanelLists(
+ items: ControlPanelItem[],
+ archivedItems: ControlPanelItem[],
+ activeChat: ActiveChatSnapshot | null,
+ opts: { chatOnStage: boolean; startedAt: string; titleForNew: string },
+): { displayItems: ControlPanelItem[]; displayArchivedItems: ControlPanelItem[] } {
+ if (!activeChat) {
+ return { displayItems: items, displayArchivedItems: archivedItems };
+ }
+ const key = `chat:${activeChat.serverId ?? activeChat.localId}`;
+ if (items.some((item) => item.key === key)) {
+ return {
+ displayItems: items.map((item) => (item.key === key ? overlayActiveChat(item, activeChat) : item)),
+ displayArchivedItems: archivedItems,
+ };
+ }
+ if (archivedItems.some((item) => item.key === key)) {
+ return {
+ displayItems: items,
+ displayArchivedItems: archivedItems.map((item) =>
+ item.key === key ? overlayActiveChat(item, activeChat) : item,
+ ),
+ };
+ }
+ if (!activeChat.hasMessages && !opts.chatOnStage) {
+ return { displayItems: items, displayArchivedItems: archivedItems };
+ }
+ return {
+ displayItems: [activeChatItem(activeChat, opts.startedAt, opts.titleForNew), ...items],
+ displayArchivedItems: archivedItems,
+ };
+}
+
+/**
+ * Archive is a flat list of chats sorted by `archivedAt` desc, with each
+ * chat's plans nested directly beneath it. Do not run this through
+ * `buildControlPanelGroups` — that pipeline day-buckets rows and only
+ * nests a plan whose parent is in the same array.
+ */
+export function buildArchivedRows(items: ControlPanelItem[]): ControlPanelRow[] {
+ const chats = items.filter((item) => item.kind === "chat");
+ const chatIds = new Set(chats.map((item) => item.id));
+ const childrenByChat = new Map();
+ for (const item of items) {
+ if (item.kind === "plan" && item.parentChatId && chatIds.has(item.parentChatId)) {
+ const list = childrenByChat.get(item.parentChatId) ?? [];
+ list.push(item);
+ childrenByChat.set(item.parentChatId, list);
+ }
+ }
+ const sortedChats = [...chats].sort((a, b) => {
+ const byArchived = (b.archivedAt ?? "").localeCompare(a.archivedAt ?? "");
+ return byArchived !== 0 ? byArchived : b.lastActivityAt.localeCompare(a.lastActivityAt);
+ });
+ const rows: ControlPanelRow[] = [];
+ for (const chat of sortedChats) {
+ const children = sortControlPanelItems(childrenByChat.get(chat.id) ?? []);
+ rows.push({
+ item: chat,
+ depth: 0,
+ childCount: children.length,
+ latestAt: chat.archivedAt ?? chat.lastActivityAt,
+ });
+ for (const child of children) {
+ rows.push({ item: child, depth: 1, parentKey: chat.key, latestAt: child.lastActivityAt });
+ }
+ }
+ return rows;
+}
+
+/** Rows the keyboard can land on. Collapsed chats hide their plans; a collapsed Archive is skipped entirely. */
+export function visibleControlPanelItems(
+ groups: ControlPanelGroup[],
+ archivedRows: ControlPanelRow[],
+ expandedKeys: ReadonlySet,
+ archivedExpanded: boolean,
+): ControlPanelItem[] {
+ const fromGroups = groups.flatMap((g) =>
+ g.rows.filter((r) => !(r.parentKey && !expandedKeys.has(r.parentKey))).map((r) => r.item),
+ );
+ if (!archivedExpanded) return fromGroups;
+ const fromArchive = archivedRows
+ .filter((r) => !(r.parentKey && !expandedKeys.has(r.parentKey)))
+ .map((r) => r.item);
+ return [...fromGroups, ...fromArchive];
+}
+
/**
* Turn the flat item list into the control panel's list: Jamie chats
* are the rows, and the plans a chat spawned nest under it. A plan