-
Notifications
You must be signed in to change notification settings - Fork 93
Add an Archived view for unarchiving chats #108
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,11 +3,10 @@ import { ArrowLeft, Flower, House, Loader2, PanelLeft, Search, Plus, Settings, S | |
| import { useLocation, useNavigate } from "react-router-dom" | ||
| import { APP_NAME } from "../../shared/branding" | ||
| import { Button } from "../components/ui/button" | ||
| import { Dialog, DialogBody, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog" | ||
| import { buildChatJumpLocationState, type ChatJumpRole } from "../lib/chat-navigation" | ||
| import { formatSidebarAgeLabel } from "../lib/formatters" | ||
| import { getSidebarChatTimestamp } from "../lib/sidebarChats" | ||
| import { cn, normalizeChatId } from "../lib/utils" | ||
| import { ArchivedChatsDialog } from "../components/chat-ui/sidebar/ArchivedChatsDialog" | ||
| import { ArchivedSection } from "../components/chat-ui/sidebar/ArchivedSection" | ||
| import { LocalProjectsSection } from "../components/chat-ui/sidebar/LocalProjectsSection" | ||
| import { FocusModePill } from "../components/chat-ui/sidebar/FocusModePill" | ||
| import { projectActivity } from "./kannaStateHelpers" | ||
|
|
@@ -30,6 +29,7 @@ import { | |
| } from "./sidebarNumberJump" | ||
| import { SIDEBAR_VIEW_STORAGE_KEY, SIDEBAR_WIDTH_STORAGE_KEY } from "../lib/storageKeys" | ||
| import { useAppSettingsStore } from "../stores/appSettingsStore" | ||
| import { usePendingSendStore } from "../stores/pendingSendStore" | ||
| import { useSidebarData } from "../stores/sidebarStore" | ||
| import { | ||
| focusSidebarData, | ||
|
|
@@ -63,7 +63,13 @@ function persistSidebarWidth(width: number) { | |
| window.localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(clampSidebarWidth(width))) | ||
| } | ||
|
|
||
| function readStoredSidebarView(): SidebarView { | ||
| /** | ||
| * The list view to start in. Only ever Chats or Projects: Archived is somewhere | ||
| * you visit and get returned from (see `leaveArchivedView`), so it is never | ||
| * persisted — and what's stored is exactly the view Archived hands you back to, | ||
| * across reloads as well as within a session. | ||
| */ | ||
| function readStoredSidebarView(): Exclude<SidebarView, "archived"> { | ||
| if (typeof window === "undefined") return "recents" | ||
| return window.localStorage.getItem(SIDEBAR_VIEW_STORAGE_KEY) === "projects" ? "projects" : "recents" | ||
| } | ||
|
|
@@ -163,13 +169,53 @@ function KannaSidebarImpl({ | |
| const [showNumberJumpHints, setShowNumberJumpHints] = useState(false) | ||
| const [sidebarWidth, setSidebarWidth] = useState(readStoredSidebarWidth) | ||
| const [isResizingSidebar, setIsResizingSidebar] = useState(false) | ||
| // Which project's archived chats the dialog is showing, if any. The | ||
| // workspace-wide list is the sidebar's own Archived view, not this. | ||
| const [archivedProjectId, setArchivedProjectId] = useState<string | null>(null) | ||
| const [sidebarView, setSidebarView] = useState<SidebarView>(readStoredSidebarView) | ||
| // Where Archived hands you back to. Held in a ref because nothing renders it | ||
| // — it is read at the moment you leave, which can be from a store | ||
| // subscription rather than a render. | ||
| const returnViewRef = useRef<Exclude<SidebarView, "archived">>(readStoredSidebarView()) | ||
|
|
||
| const changeSidebarView = useCallback((view: SidebarView) => { | ||
| setSidebarView(view) | ||
| if (view === "archived") return | ||
| returnViewRef.current = view | ||
| if (typeof window !== "undefined") window.localStorage.setItem(SIDEBAR_VIEW_STORAGE_KEY, view) | ||
| }, []) | ||
|
|
||
| /** | ||
| * Leave the Archived view for the one you were in before it — a no-op from | ||
| * anywhere else, so callers don't have to check where they are. | ||
| * | ||
| * The archive is where finished work goes, so anything that puts a chat back | ||
| * into circulation has ended your visit: sending a prompt (which unarchives | ||
| * the chat server-side, or was a new chat that was never in this list) and | ||
| * restoring one. Staying put would leave you looking at a list the chat you | ||
| * just acted on has dropped out of. | ||
| */ | ||
| const leaveArchivedView = useCallback(() => { | ||
| setSidebarView((current) => (current === "archived" ? returnViewRef.current : current)) | ||
| }, []) | ||
|
|
||
| const handleRestoreChat = useCallback((chatId: string) => { | ||
| leaveArchivedView() | ||
| onRestoreChat(chatId) | ||
| }, [leaveArchivedView, onRestoreChat]) | ||
|
|
||
| // Sends come from the composer, which is not in this tree — the pending-send | ||
| // store is the one place both sides already meet. Subscribed only while the | ||
| // Archived view is up, so every other view pays nothing for this. | ||
| useEffect(() => { | ||
| if (sidebarView !== "archived") return | ||
| return usePendingSendStore.subscribe((state, previous) => { | ||
| if (state.sentAt === previous.sentAt) return | ||
| const started = Object.keys(state.sentAt) | ||
| .some((chatId) => state.sentAt[chatId] !== previous.sentAt[chatId]) | ||
| if (started) leaveArchivedView() | ||
| }) | ||
| }, [leaveArchivedView, sidebarView]) | ||
| const resolvedKeybindings = useMemo(() => getResolvedKeybindings(keybindings), [keybindings]) | ||
| const visibleChats = useMemo( | ||
| () => getVisibleSidebarChats(data.projectGroups, collapsedSections, expandedGroups), | ||
|
|
@@ -302,11 +348,11 @@ function KannaSidebarImpl({ | |
| onOpenExternalPath={onOpenExternalPath} | ||
| onForkChat={onForkChat} | ||
| onArchiveChat={onArchiveChat} | ||
| onRestoreChat={onRestoreChat} | ||
| onRestoreChat={handleRestoreChat} | ||
| onDeleteChat={onDeleteChat} | ||
| /> | ||
| ) | ||
| }, [activeChatId, editorLabel, nowMs, onArchiveChat, onCopyPath, onCreateChat, onDeleteChat, onForkChat, onOpenExternalPath, onRenameChat, onRestoreChat, onShareChat, resolvedKeybindings, selectChat, showNumberJumpHints, threadByChatId, visibleIndexByChatId]) | ||
| }, [activeChatId, editorLabel, nowMs, onArchiveChat, onCopyPath, onCreateChat, onDeleteChat, onForkChat, onOpenExternalPath, onRenameChat, handleRestoreChat, onShareChat, resolvedKeybindings, selectChat, showNumberJumpHints, threadByChatId, visibleIndexByChatId]) | ||
|
|
||
| useEffect(() => { | ||
| const intervalId = window.setInterval(() => { | ||
|
|
@@ -723,7 +769,10 @@ function KannaSidebarImpl({ | |
| </div> | ||
| ) : null} | ||
|
|
||
| {!isConnecting && ( | ||
| {/* Not in the Archived view: there, "no conversations yet" would sit | ||
| above a list of the conversations you archived, and the view | ||
| states its own emptiness anyway. */} | ||
| {!isConnecting && sidebarView !== "archived" && ( | ||
| (!hasVisibleChats && data.projectGroups.length === 0) | ||
| // A focused project with no chats: say so, rather than leave the | ||
| // list blank under a pill naming the project. | ||
|
|
@@ -740,7 +789,26 @@ function KannaSidebarImpl({ | |
| nowMs={nowMs} | ||
| onSelectChat={selectChat} | ||
| onOpenArchivedChat={onOpenArchivedChat} | ||
| onRestoreChat={onRestoreChat} | ||
| onRestoreChat={handleRestoreChat} | ||
| onCreateChat={onCreateChat} | ||
| onRenameChat={onRenameChat} | ||
| onShareChat={onShareChat} | ||
| onForkChat={onForkChat} | ||
| onArchiveChat={onArchiveChat} | ||
| onDeleteChat={onDeleteChat} | ||
| onCopyPath={onCopyPath} | ||
| onOpenExternalPath={onOpenExternalPath} | ||
| /> | ||
| ) : null} | ||
|
|
||
| {newSidebarEnabled && sidebarView === "archived" ? ( | ||
| <ArchivedSection | ||
| threads={threads} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When focus mode is enabled, Knowledge Base Used: Chat workspace experience Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
| activeChatId={activeChatId} | ||
| editorLabel={editorLabel} | ||
| nowMs={nowMs} | ||
| onOpenArchivedChat={onOpenArchivedChat} | ||
| onRestoreChat={handleRestoreChat} | ||
| onCreateChat={onCreateChat} | ||
| onRenameChat={onRenameChat} | ||
| onShareChat={onShareChat} | ||
|
|
@@ -862,43 +930,17 @@ function KannaSidebarImpl({ | |
| /> | ||
| </div> | ||
|
|
||
| <Dialog | ||
| <ArchivedChatsDialog | ||
| open={Boolean(archivedProject)} | ||
| description={archivedProject?.localPath} | ||
| chats={archivedProject?.archivedChats ?? []} | ||
| nowMs={nowMs} | ||
| onOpenChange={(dialogOpen) => { | ||
| if (!dialogOpen) setArchivedProjectId(null) | ||
| }} | ||
| > | ||
| <DialogContent size="md"> | ||
| <DialogHeader> | ||
| <DialogTitle>Archived Chats</DialogTitle> | ||
| <DialogDescription> | ||
| {archivedProject?.localPath ?? ""} | ||
| </DialogDescription> | ||
| </DialogHeader> | ||
| <DialogBody className="space-y-1"> | ||
| {archivedProject?.archivedChats?.length ? ( | ||
| archivedProject.archivedChats.map((chat) => ( | ||
| <button | ||
| key={chat.chatId} | ||
| type="button" | ||
| className="flex w-full items-center justify-between gap-3 rounded-lg border border-border/0 px-3 py-2 text-left transition-colors hover:border-border hover:bg-muted" | ||
| onClick={() => { | ||
| onOpenArchivedChat(chat.chatId) | ||
| setArchivedProjectId(null) | ||
| }} | ||
| > | ||
| <span className="min-w-0 truncate text-sm">{chat.title}</span> | ||
| <span className="shrink-0 text-xs text-muted-foreground"> | ||
| {formatSidebarAgeLabel(getSidebarChatTimestamp(chat), nowMs)} | ||
| </span> | ||
| </button> | ||
| )) | ||
| ) : ( | ||
| <p className="px-1 py-3 text-sm text-muted-foreground">No archived chats</p> | ||
| )} | ||
| </DialogBody> | ||
| </DialogContent> | ||
| </Dialog> | ||
| onOpenChat={onOpenArchivedChat} | ||
| onRestoreChat={handleRestoreChat} | ||
| /> | ||
| </> | ||
| ) | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| import { RotateCcw } from "lucide-react" | ||
| import type { SidebarChatRow } from "../../../../shared/types" | ||
| import { formatSidebarAgeLabel } from "../../../lib/formatters" | ||
| import { getSidebarChatTimestamp } from "../../../lib/sidebarChats" | ||
| import { Button } from "../../ui/button" | ||
| import { | ||
| Dialog, | ||
| DialogBody, | ||
| DialogContent, | ||
| DialogDescription, | ||
| DialogHeader, | ||
| DialogTitle, | ||
| } from "../../ui/dialog" | ||
|
|
||
| /** | ||
| * One project's archived chats, opened from its sidebar menu — the Projects | ||
| * view's counterpart to the Archived view, which covers the whole workspace. | ||
| * | ||
| * Opening a chat closes the dialog — you're leaving for it — while restoring | ||
| * leaves it open, since putting several chats back is one errand and each row | ||
| * drops out of the list on the next snapshot anyway. | ||
| */ | ||
| export function ArchivedChatsDialog({ | ||
| open, | ||
| description, | ||
| chats, | ||
| nowMs, | ||
| onOpenChange, | ||
| onOpenChat, | ||
| onRestoreChat, | ||
| }: { | ||
| open: boolean | ||
| /** Subtitle under the heading: the project's path. */ | ||
| description?: string | ||
| chats: SidebarChatRow[] | ||
| nowMs: number | ||
| onOpenChange: (open: boolean) => void | ||
| onOpenChat: (chatId: string) => void | ||
| onRestoreChat: (chatId: string) => void | ||
| }) { | ||
| return ( | ||
| <Dialog open={open} onOpenChange={onOpenChange}> | ||
| <DialogContent size="md"> | ||
| <DialogHeader> | ||
| <DialogTitle>Archived Chats</DialogTitle> | ||
| <DialogDescription>{description ?? ""}</DialogDescription> | ||
| </DialogHeader> | ||
| <DialogBody className="space-y-1"> | ||
| {chats.length ? ( | ||
| chats.map((chat) => ( | ||
| <div | ||
| key={chat.chatId} | ||
| className="group flex items-center gap-1 rounded-lg border border-border/0 pr-1 transition-colors hover:border-border hover:bg-muted" | ||
| > | ||
| <button | ||
| type="button" | ||
| className="flex min-w-0 flex-1 items-center justify-between gap-3 px-3 py-2 text-left" | ||
| onClick={() => { | ||
| onOpenChat(chat.chatId) | ||
| onOpenChange(false) | ||
| }} | ||
| > | ||
| <span className="min-w-0 truncate text-sm">{chat.title}</span> | ||
| <span className="shrink-0 text-xs text-muted-foreground"> | ||
| {formatSidebarAgeLabel(chat.archivedAt ?? getSidebarChatTimestamp(chat), nowMs)} | ||
| </span> | ||
| </button> | ||
| <Button | ||
| variant="ghost" | ||
| size="icon" | ||
| className="h-7 w-7 shrink-0 rounded-md text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100" | ||
| title="Restore chat" | ||
| aria-label={`Restore ${chat.title}`} | ||
| onClick={() => onRestoreChat(chat.chatId)} | ||
| > | ||
| <RotateCcw className="size-3.5" /> | ||
| </Button> | ||
| </div> | ||
| )) | ||
| ) : ( | ||
| <p className="px-1 py-3 text-sm text-muted-foreground">No archived chats</p> | ||
| )} | ||
| </DialogBody> | ||
| </DialogContent> | ||
| </Dialog> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| import { describe, expect, test } from "bun:test" | ||
| import { renderToStaticMarkup } from "react-dom/server" | ||
| import type { SidebarChatRow } from "../../../../shared/types" | ||
| import type { SidebarThread } from "../../../lib/thread-sections" | ||
| import { TooltipProvider } from "../../ui/tooltip" | ||
| import { ArchivedSection } from "./ArchivedSection" | ||
|
|
||
| function thread( | ||
| overrides: Partial<SidebarChatRow> & Pick<SidebarChatRow, "chatId" | "title">, | ||
| archived = true | ||
| ): SidebarThread { | ||
| const row: SidebarChatRow = { | ||
| _id: overrides.chatId, | ||
| _creationTime: 1, | ||
| status: "idle", | ||
| unread: false, | ||
| localPath: "/tmp/project", | ||
| provider: "claude", | ||
| lastMessageAt: 1, | ||
| hasAutomation: false, | ||
| ...overrides, | ||
| } | ||
| return { | ||
| chatId: row.chatId, | ||
| title: row.title, | ||
| projectId: "project-1", | ||
| projectTitle: "Project", | ||
| projectLabel: { name: "Project", branchName: "main", repoPath: "acme/Project", text: "Project/main" }, | ||
| archived, | ||
| lastActivityAt: row.lastMessageAt ?? 1, | ||
| row, | ||
| } | ||
| } | ||
|
|
||
| function render(threads: SidebarThread[]) { | ||
| return renderToStaticMarkup( | ||
| // The rows are hover-card triggers, and Radix tooltips need their provider. | ||
| <TooltipProvider> | ||
| <ArchivedSection | ||
| threads={threads} | ||
| activeChatId={null} | ||
| editorLabel="VS Code" | ||
| nowMs={1_000} | ||
| onOpenArchivedChat={() => undefined} | ||
| onRestoreChat={() => undefined} | ||
| onCreateChat={() => undefined} | ||
| onRenameChat={() => undefined} | ||
| onShareChat={() => undefined} | ||
| onForkChat={() => undefined} | ||
| onArchiveChat={() => undefined} | ||
| onDeleteChat={() => undefined} | ||
| onCopyPath={() => undefined} | ||
| onOpenExternalPath={() => undefined} | ||
| /> | ||
| </TooltipProvider> | ||
| ) | ||
| } | ||
|
|
||
| describe("ArchivedSection", () => { | ||
| test("lists only archived chats, most recently archived first", () => { | ||
| const markup = render([ | ||
| thread({ chatId: "active", title: "Still going", archivedAt: undefined }, false), | ||
| thread({ chatId: "older", title: "Archived earlier", archivedAt: 100 }), | ||
| thread({ chatId: "newer", title: "Archived just now", archivedAt: 900 }), | ||
| ]) | ||
|
|
||
| expect(markup).not.toContain("Still going") | ||
| expect(markup.indexOf("Archived just now")).toBeLessThan(markup.indexOf("Archived earlier")) | ||
| }) | ||
|
|
||
| test("says so when there is nothing archived", () => { | ||
| expect(render([thread({ chatId: "active", title: "Still going" }, false)])) | ||
| .toContain("No archived chats") | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The sidebar leaves the Archived view before the asynchronous
chat.unarchivecommand succeeds. If that command fails because of a connection or server error, the chat remains archived, but the user is returned to Chats or Projects and loses the archive context needed to retry.Knowledge Base Used: Chat workspace experience