diff --git a/ui/goose2/scripts/check-file-sizes.mjs b/ui/goose2/scripts/check-file-sizes.mjs index 65db5ce277..4e09c18f86 100644 --- a/ui/goose2/scripts/check-file-sizes.mjs +++ b/ui/goose2/scripts/check-file-sizes.mjs @@ -6,9 +6,18 @@ const DEFAULT_LIMIT = 500; // Add narrowly scoped exceptions here with justification const EXCEPTIONS = { "src/app/AppShell.tsx": { - limit: 560, + limit: 580, justification: - "Temporary allowance while project creation and home/chat handoff wiring remain in the shell.", + "Temporary allowance while project creation, home/chat handoff, and session-history routing remain in the shell.", + }, + "src/features/chat/ui/ChatView.tsx": { + limit: 505, + justification: + "Added useSessionAutoTitle hook call for smart session naming.", + }, + "src/features/sidebar/ui/Sidebar.tsx": { + limit: 575, + justification: "Added search-as-you-type filtering for sidebar sessions.", }, }; diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index 60d03bfd1f..a3d3fd1cad 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -7,6 +7,7 @@ import type { PastedImage } from "@/shared/types/messages"; import { SkillsView } from "@/features/skills/ui/SkillsView"; import { AgentsView } from "@/features/agents/ui/AgentsView"; import { ProjectsView } from "@/features/projects/ui/ProjectsView"; +import { SessionHistoryView } from "@/features/sessions/ui/SessionHistoryView"; import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog"; import { archiveProject } from "@/features/projects/api/projects"; import type { ProjectInfo } from "@/features/projects/api/projects"; @@ -21,7 +22,13 @@ import { findExistingDraft } from "@/features/chat/lib/newChat"; import { getSessionMessages } from "@/shared/api/chat"; import { useAppStartup } from "./hooks/useAppStartup"; -export type AppView = "home" | "chat" | "skills" | "agents" | "projects"; +export type AppView = + | "home" + | "chat" + | "skills" + | "agents" + | "projects" + | "session-history"; const SIDEBAR_DEFAULT_WIDTH = 240; const SIDEBAR_MIN_WIDTH = 180; @@ -273,7 +280,10 @@ export function AppShell({ children }: { children?: React.ReactNode }) { const handleRenameChat = useCallback( (sessionId: string, nextTitle: string) => { - sessionStore.updateSession(sessionId, { title: nextTitle }); + sessionStore.updateSession(sessionId, { + title: nextTitle, + userSetName: true, + }); }, [sessionStore], ); @@ -425,6 +435,14 @@ export function AppShell({ children }: { children?: React.ReactNode }) { return ; case "projects": return ; + case "session-history": + return ( + + ); case "chat": case "home": return activeSession ? ( diff --git a/ui/goose2/src/features/chat/stores/chatSessionStore.ts b/ui/goose2/src/features/chat/stores/chatSessionStore.ts index cfd10bd457..b159e00152 100644 --- a/ui/goose2/src/features/chat/stores/chatSessionStore.ts +++ b/ui/goose2/src/features/chat/stores/chatSessionStore.ts @@ -24,6 +24,7 @@ export interface ChatSession { archivedAt?: string; messageCount: number; draft?: boolean; + userSetName?: boolean; } interface ChatSessionStoreState { @@ -105,6 +106,7 @@ function sessionToChatSession(session: Session): ChatSession { updatedAt: session.updatedAt, archivedAt: session.archivedAt, messageCount: session.messageCount, + userSetName: session.userSetName, }; } @@ -219,6 +221,7 @@ export const useChatSessionStore = create((set, get) => ({ personaId?: string; modelName?: string; projectId?: string | null; + userSetName?: boolean; } = {}; if (patch.title) backendPatch.title = patch.title; if (patch.providerId) backendPatch.providerId = patch.providerId; @@ -227,6 +230,9 @@ export const useChatSessionStore = create((set, get) => ({ if ("projectId" in patch) { backendPatch.projectId = patch.projectId ?? null; } + if ("userSetName" in patch) { + backendPatch.userSetName = patch.userSetName; + } if (Object.keys(backendPatch).length > 0) { apiUpdateSession(id, backendPatch).catch((err) => { console.error("Failed to persist session update:", err); diff --git a/ui/goose2/src/features/chat/ui/ChatView.tsx b/ui/goose2/src/features/chat/ui/ChatView.tsx index 29f2583634..99cfae923d 100644 --- a/ui/goose2/src/features/chat/ui/ChatView.tsx +++ b/ui/goose2/src/features/chat/ui/ChatView.tsx @@ -10,6 +10,7 @@ import type { PastedImage } from "@/shared/types/messages"; import { LoadingGoose } from "./LoadingGoose"; import { ContextPanel } from "./ContextPanel"; import { useChat } from "../hooks/useChat"; +import { useSessionAutoTitle } from "@/features/sessions/hooks/useSessionAutoTitle"; import { useChatStore } from "../stores/chatStore"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useProviderSelection } from "@/features/agents/hooks/useProviderSelection"; @@ -294,6 +295,8 @@ export function ChatView({ effectiveWorkingDir, ); + useSessionAutoTitle(activeSessionId); + const deferredSend = useRef<{ text: string; images?: PastedImage[] } | null>( null, ); diff --git a/ui/goose2/src/features/sessions/hooks/__tests__/useSessionAutoTitle.test.ts b/ui/goose2/src/features/sessions/hooks/__tests__/useSessionAutoTitle.test.ts new file mode 100644 index 0000000000..5cf7eca6ad --- /dev/null +++ b/ui/goose2/src/features/sessions/hooks/__tests__/useSessionAutoTitle.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useChatStore } from "@/features/chat/stores/chatStore"; +import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; + +// Mock the API module — include updateSession since chatSessionStore imports it +vi.mock("@/shared/api/chat", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + generateSessionTitle: vi.fn(), + updateSession: vi.fn().mockResolvedValue(undefined), + }; +}); + +import { generateSessionTitle } from "@/shared/api/chat"; +import { useSessionAutoTitle } from "../useSessionAutoTitle"; + +const mockGenerateTitle = vi.mocked(generateSessionTitle); + +function seedSession( + id: string, + overrides: Partial<{ + title: string; + userSetName: boolean; + draft: boolean; + messageCount: number; + }> = {}, +) { + const now = new Date().toISOString(); + useChatSessionStore.setState({ + sessions: [ + { + id, + title: overrides.title ?? "New Chat", + createdAt: now, + updatedAt: now, + messageCount: overrides.messageCount ?? 0, + draft: overrides.draft, + userSetName: overrides.userSetName, + }, + ], + }); +} + +describe("useSessionAutoTitle", () => { + beforeEach(() => { + vi.clearAllMocks(); + useChatStore.setState({ + messagesBySession: {}, + sessionStateById: {}, + draftsBySession: {}, + activeSessionId: null, + isConnected: false, + }); + useChatSessionStore.setState({ + sessions: [], + activeSessionId: null, + isLoading: false, + contextPanelOpenBySession: {}, + }); + }); + + it("does not trigger when session has userSetName", () => { + seedSession("s1", { userSetName: true, messageCount: 2 }); + useChatStore.getState().setChatState("s1", "idle"); + + renderHook(() => useSessionAutoTitle("s1")); + + expect(mockGenerateTitle).not.toHaveBeenCalled(); + }); + + it("does not trigger when messageCount is not 2", () => { + seedSession("s1", { messageCount: 0 }); + useChatStore.getState().setChatState("s1", "idle"); + + renderHook(() => useSessionAutoTitle("s1")); + + expect(mockGenerateTitle).not.toHaveBeenCalled(); + }); + + it("generates title when first exchange completes", async () => { + seedSession("s1", { messageCount: 2 }); + mockGenerateTitle.mockResolvedValue("Fix sidebar resize bug"); + + // Start in streaming state + useChatStore.getState().setChatState("s1", "streaming"); + + const { rerender } = renderHook(() => useSessionAutoTitle("s1")); + + // Transition to idle (stream completed) + useChatStore.getState().setChatState("s1", "idle"); + rerender(); + + // Wait for the async call + await vi.waitFor(() => { + expect(mockGenerateTitle).toHaveBeenCalledWith("s1"); + }); + }); + + it("uses fallback title on API failure", async () => { + seedSession("s1", { messageCount: 2 }); + mockGenerateTitle.mockRejectedValue(new Error("LLM unavailable")); + + // Add a user message so fallback can extract text + useChatStore.getState().addMessage("s1", { + id: "msg-1", + role: "user", + created: Date.now(), + content: [ + { + type: "text", + text: "Help me fix the authentication middleware in the login flow", + }, + ], + metadata: { userVisible: true }, + }); + + useChatStore.getState().setChatState("s1", "streaming"); + + const { rerender } = renderHook(() => useSessionAutoTitle("s1")); + + useChatStore.getState().setChatState("s1", "idle"); + rerender(); + + // Wait for the rejection to be handled and fallback title to be set + await vi.waitFor(() => { + expect(mockGenerateTitle).toHaveBeenCalledWith("s1"); + // Should have updated session with fallback (first 50 chars of user message) + const session = useChatSessionStore.getState().getSession("s1"); + expect(session?.title).toBe( + "Help me fix the authentication middleware in the l", + ); + }); + }); +}); diff --git a/ui/goose2/src/features/sessions/hooks/useSessionAutoTitle.ts b/ui/goose2/src/features/sessions/hooks/useSessionAutoTitle.ts new file mode 100644 index 0000000000..2847b0feea --- /dev/null +++ b/ui/goose2/src/features/sessions/hooks/useSessionAutoTitle.ts @@ -0,0 +1,59 @@ +import { useEffect, useRef } from "react"; +import { useChatStore } from "@/features/chat/stores/chatStore"; +import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; +import { generateSessionTitle } from "@/shared/api/chat"; + +/** + * Automatically generates a session title after the first exchange completes. + * Watches for the transition from streaming → idle when messageCount === 2. + * Skips sessions where the user has manually set a title. + */ +export function useSessionAutoTitle(sessionId: string) { + const firedRef = useRef(false); + const prevChatStateRef = useRef(null); + + const chatState = useChatStore( + (s) => s.getSessionRuntime(sessionId).chatState, + ); + const session = useChatSessionStore((s) => + s.sessions.find((candidate) => candidate.id === sessionId), + ); + + useEffect(() => { + if (firedRef.current) return; + if (!session || session.userSetName) return; + if (session.messageCount !== 2) { + prevChatStateRef.current = chatState; + return; + } + + const wasStreaming = prevChatStateRef.current === "streaming"; + prevChatStateRef.current = chatState; + + if (chatState !== "idle" || !wasStreaming) return; + + firedRef.current = true; + + generateSessionTitle(sessionId) + .then((title) => { + useChatSessionStore + .getState() + .updateSession(sessionId, { title, userSetName: false }); + }) + .catch(() => { + // Fallback: use first 50 chars of first user message + const messages = + useChatStore.getState().messagesBySession[sessionId] ?? []; + const firstUserMessage = messages.find((m) => m.role === "user"); + const textBlock = firstUserMessage?.content.find( + (c) => c.type === "text", + ); + if (textBlock && "text" in textBlock) { + const fallback = textBlock.text.trim().slice(0, 50); + useChatSessionStore + .getState() + .updateSession(sessionId, { title: fallback, userSetName: false }); + } + }); + }, [sessionId, chatState, session]); +} diff --git a/ui/goose2/src/features/sessions/lib/filterSessions.test.ts b/ui/goose2/src/features/sessions/lib/filterSessions.test.ts new file mode 100644 index 0000000000..23f748c4e7 --- /dev/null +++ b/ui/goose2/src/features/sessions/lib/filterSessions.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { filterSessions } from "./filterSessions"; +import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; + +const resolvers = { + getPersonaName: (id: string) => (id === "p1" ? "Code Assistant" : undefined), + getProjectName: (id: string) => + id === "proj1" ? "Goose2 Frontend" : undefined, +}; + +function makeSession( + overrides: Partial & { id: string }, +): ChatSession { + return { + title: "Untitled", + createdAt: "2026-04-07T12:00:00Z", + updatedAt: "2026-04-07T12:00:00Z", + messageCount: 3, + ...overrides, + }; +} + +describe("filterSessions", () => { + const sessions: ChatSession[] = [ + makeSession({ id: "1", title: "Fix sidebar bug", personaId: "p1" }), + makeSession({ + id: "2", + title: "Add pagination", + projectId: "proj1", + }), + makeSession({ id: "3", title: "Debug auth flow" }), + ]; + + it("returns all sessions for empty query", () => { + expect(filterSessions(sessions, "", resolvers)).toEqual(sessions); + }); + + it("filters by title", () => { + const result = filterSessions(sessions, "sidebar", resolvers); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("1"); + }); + + it("filters by persona name", () => { + const result = filterSessions(sessions, "code assistant", resolvers); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("1"); + }); + + it("filters by project name", () => { + const result = filterSessions(sessions, "frontend", resolvers); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("2"); + }); + + it("is case-insensitive", () => { + const result = filterSessions(sessions, "DEBUG", resolvers); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("3"); + }); + + it("matches across multiple fields", () => { + const result = filterSessions(sessions, "fix", resolvers); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("1"); + }); +}); diff --git a/ui/goose2/src/features/sessions/lib/filterSessions.ts b/ui/goose2/src/features/sessions/lib/filterSessions.ts new file mode 100644 index 0000000000..cb94980ad6 --- /dev/null +++ b/ui/goose2/src/features/sessions/lib/filterSessions.ts @@ -0,0 +1,48 @@ +import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; + +export interface FilterResolvers { + getPersonaName: (personaId: string) => string | undefined; + getProjectName: (projectId: string) => string | undefined; +} + +function buildSearchableString( + session: ChatSession, + resolvers: FilterResolvers, +): string { + const parts: string[] = [session.title]; + + if (session.personaId) { + const name = resolvers.getPersonaName(session.personaId); + if (name) parts.push(name); + } + + if (session.projectId) { + const name = resolvers.getProjectName(session.projectId); + if (name) parts.push(name); + } + + const date = new Date(session.updatedAt); + parts.push( + date.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + year: "numeric", + }), + ); + + return parts.join(" ").toLowerCase(); +} + +export function filterSessions( + sessions: ChatSession[], + query: string, + resolvers: FilterResolvers, +): ChatSession[] { + const trimmed = query.trim().toLowerCase(); + if (!trimmed) return sessions; + + return sessions.filter((session) => + buildSearchableString(session, resolvers).includes(trimmed), + ); +} diff --git a/ui/goose2/src/features/sessions/lib/groupSessionsByDate.test.ts b/ui/goose2/src/features/sessions/lib/groupSessionsByDate.test.ts new file mode 100644 index 0000000000..e1fa247c71 --- /dev/null +++ b/ui/goose2/src/features/sessions/lib/groupSessionsByDate.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { groupSessionsByDate } from "./groupSessionsByDate"; +import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; + +function makeSession(id: string, updatedAt: string): ChatSession { + return { + id, + title: `Session ${id}`, + createdAt: updatedAt, + updatedAt, + messageCount: 5, + }; +} + +describe("groupSessionsByDate", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-07T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("groups sessions into Today, Yesterday, and dated buckets", () => { + const sessions = [ + makeSession("a", "2026-04-07T10:00:00Z"), + makeSession("b", "2026-04-07T08:00:00Z"), + makeSession("c", "2026-04-06T15:00:00Z"), + makeSession("d", "2026-03-28T12:00:00Z"), + ]; + + const groups = groupSessionsByDate(sessions); + + expect(groups).toHaveLength(3); + expect(groups[0].label).toBe("Today"); + expect(groups[0].sessions).toHaveLength(2); + expect(groups[0].sessions[0].id).toBe("a"); + expect(groups[1].label).toBe("Yesterday"); + expect(groups[1].sessions).toHaveLength(1); + expect(groups[2].label).toBe("March 28, 2026"); + expect(groups[2].sessions).toHaveLength(1); + }); + + it("returns empty array for no sessions", () => { + expect(groupSessionsByDate([])).toEqual([]); + }); + + it("sorts sessions within each group newest-first", () => { + const sessions = [ + makeSession("early", "2026-04-07T06:00:00Z"), + makeSession("late", "2026-04-07T11:00:00Z"), + makeSession("mid", "2026-04-07T09:00:00Z"), + ]; + + const groups = groupSessionsByDate(sessions); + const ids = groups[0].sessions.map((s) => s.id); + expect(ids).toEqual(["late", "mid", "early"]); + }); +}); diff --git a/ui/goose2/src/features/sessions/lib/groupSessionsByDate.ts b/ui/goose2/src/features/sessions/lib/groupSessionsByDate.ts new file mode 100644 index 0000000000..c92f0a3a8f --- /dev/null +++ b/ui/goose2/src/features/sessions/lib/groupSessionsByDate.ts @@ -0,0 +1,58 @@ +import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; + +export interface SessionDateGroup { + label: string; + sessions: ChatSession[]; +} + +function startOfDayUTC(date: Date): number { + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); +} + +function formatDateLabel(date: Date, today: Date): string { + const todayStart = startOfDayUTC(today); + const dateStart = startOfDayUTC(date); + const diff = todayStart - dateStart; + + if (diff === 0) return "Today"; + if (diff === 86_400_000) return "Yesterday"; + + return date.toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + timeZone: "UTC", + }); +} + +export function groupSessionsByDate( + sessions: ChatSession[], +): SessionDateGroup[] { + if (sessions.length === 0) return []; + + const now = new Date(); + const buckets = new Map(); + const labelOrder: string[] = []; + + const sorted = [...sessions].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + + for (const session of sorted) { + const date = new Date(session.updatedAt); + const label = formatDateLabel(date, now); + + let bucket = buckets.get(label); + if (!bucket) { + bucket = []; + buckets.set(label, bucket); + labelOrder.push(label); + } + bucket.push(session); + } + + return labelOrder.map((label) => ({ + label, + sessions: buckets.get(label) ?? [], + })); +} diff --git a/ui/goose2/src/features/sessions/ui/SessionCard.tsx b/ui/goose2/src/features/sessions/ui/SessionCard.tsx new file mode 100644 index 0000000000..07d4b4ef1b --- /dev/null +++ b/ui/goose2/src/features/sessions/ui/SessionCard.tsx @@ -0,0 +1,237 @@ +import { useEffect, useRef, useState } from "react"; +import { + Calendar, + MessageSquare, + Folder, + Bot, + MoreHorizontal, + Pencil, + Trash2, + ArchiveRestore, +} from "lucide-react"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { Input } from "@/shared/ui/input"; + +function formatRelativeDate(isoString: string): string { + const date = new Date(isoString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMs / 3_600_000); + const diffDays = Math.floor(diffMs / 86_400_000); + + if (diffMins < 1) return "Just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays === 1) return "Yesterday"; + if (diffDays < 7) return `${diffDays}d ago`; + + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); +} + +interface SessionCardProps { + id: string; + title: string; + updatedAt: string; + messageCount: number; + personaName?: string; + projectName?: string; + projectColor?: string; + workingDir?: string; + archivedAt?: string; + onSelect?: (id: string) => void; + onRename?: (id: string, nextTitle: string) => void; + onArchive?: (id: string) => void; + onUnarchive?: (id: string) => void; +} + +export function SessionCard({ + id, + title, + updatedAt, + messageCount, + personaName, + projectName, + projectColor, + workingDir, + archivedAt, + onSelect, + onRename, + onArchive, + onUnarchive, +}: SessionCardProps) { + const [menuOpen, setMenuOpen] = useState(false); + const [editing, setEditing] = useState(false); + const [draftTitle, setDraftTitle] = useState(title); + const inputRef = useRef(null); + + useEffect(() => { + setDraftTitle(title); + }, [title]); + + useEffect(() => { + if (!editing) return; + inputRef.current?.focus(); + inputRef.current?.select(); + }, [editing]); + + const startRename = () => { + setDraftTitle(title); + setMenuOpen(false); + setEditing(true); + }; + + const commitRename = () => { + const nextTitle = draftTitle.trim(); + setEditing(false); + if (!nextTitle || nextTitle === title) return; + onRename?.(id, nextTitle); + }; + + const cancelRename = () => { + setDraftTitle(title); + setEditing(false); + }; + + return ( +
+ {/* Click-to-open overlay */} + + + + {archivedAt ? ( + { + setMenuOpen(false); + onUnarchive?.(id); + }} + > + + Restore + + ) : ( + <> + + + Rename + + { + setMenuOpen(false); + onArchive?.(id); + }} + > + + Archive + + + )} + + +
+ ); +} diff --git a/ui/goose2/src/features/sessions/ui/SessionHistoryView.tsx b/ui/goose2/src/features/sessions/ui/SessionHistoryView.tsx new file mode 100644 index 0000000000..ba4e9276db --- /dev/null +++ b/ui/goose2/src/features/sessions/ui/SessionHistoryView.tsx @@ -0,0 +1,230 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Archive, History } from "lucide-react"; +import { SearchBar } from "@/shared/ui/SearchBar"; +import { Button } from "@/shared/ui/button"; +import { SessionCard } from "./SessionCard"; +import { groupSessionsByDate } from "../lib/groupSessionsByDate"; +import { filterSessions } from "../lib/filterSessions"; +import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; +import { useProjectStore } from "@/features/projects/stores/projectStore"; +import { listArchivedSessions } from "@/shared/api/chat"; +import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; + +interface SessionHistoryViewProps { + onSelectSession?: (sessionId: string) => void; + onRenameChat?: (sessionId: string, nextTitle: string) => void; + onArchiveChat?: (sessionId: string) => void; +} + +export function SessionHistoryView({ + onSelectSession, + onRenameChat, + onArchiveChat, +}: SessionHistoryViewProps) { + const sessions = useChatSessionStore((s) => s.sessions); + const activeSessions = useMemo( + () => sessions.filter((session) => !session.draft), + [sessions], + ); + const [archivedSessions, setArchivedSessions] = useState([]); + const [showArchived, setShowArchived] = useState(false); + const [search, setSearch] = useState(""); + + const loadArchived = useCallback(() => { + let cancelled = false; + listArchivedSessions() + .then((result) => { + if (cancelled) return; + setArchivedSessions( + result.map((s) => ({ + id: s.id, + title: s.title, + projectId: s.projectId, + agentId: s.agentId, + providerId: s.providerId, + personaId: s.personaId, + modelName: s.modelName, + createdAt: s.createdAt, + updatedAt: s.updatedAt, + archivedAt: s.archivedAt, + messageCount: s.messageCount, + userSetName: s.userSetName, + })), + ); + }) + .catch(() => { + if (!cancelled) setArchivedSessions([]); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(loadArchived, [loadArchived]); + + const displaySessions = showArchived ? archivedSessions : activeSessions; + + const getPersonaName = useCallback( + (personaId: string) => + useAgentStore.getState().getPersonaById(personaId)?.displayName, + [], + ); + + const projects = useProjectStore((s) => s.projects); + const getProjectName = useCallback( + (projectId: string) => projects.find((p) => p.id === projectId)?.name, + [projects], + ); + + const getProjectColor = useCallback( + (projectId: string) => projects.find((p) => p.id === projectId)?.color, + [projects], + ); + + const getWorkingDir = useCallback( + (projectId: string) => + projects.find((p) => p.id === projectId)?.workingDirs[0], + [projects], + ); + + const resolvers = { getPersonaName, getProjectName }; + const filtered = filterSessions(displaySessions, search, resolvers); + const dateGroups = groupSessionsByDate(filtered); + + const handleUnarchive = useCallback( + async (sessionId: string) => { + try { + await useChatSessionStore.getState().unarchiveSession(sessionId); + loadArchived(); + } catch { + // best-effort + } + }, + [loadArchived], + ); + + const handleArchive = useCallback( + async (sessionId: string) => { + onArchiveChat?.(sessionId); + // Refresh archived list after a short delay so the backend has time to persist + setTimeout(loadArchived, 300); + }, + [onArchiveChat, loadArchived], + ); + + const emptyLabel = showArchived ? "No archived sessions" : "No sessions yet"; + const emptyHint = showArchived + ? "Archived sessions will appear here." + : "Start a chat to see it here."; + + return ( +
+
+
+ {/* Header */} +
+
+

+ {showArchived ? "Archived Sessions" : "Session History"} +

+

+ {showArchived + ? "Sessions you've archived" + : "Browse and search past sessions"} +

+
+ +
+ + {/* Search */} + + + {/* Session cards grouped by date */} + {dateGroups.length > 0 && + dateGroups.map((group) => ( +
+

+ {group.label} +

+
+ {group.sessions.map((session) => ( + + ))} +
+
+ ))} + + {/* Empty state */} + {dateGroups.length === 0 && ( +
+ +
+

+ {displaySessions.length === 0 + ? emptyLabel + : "No matching sessions"} +

+

+ {displaySessions.length === 0 + ? emptyHint + : "Try a different search term."} +

+
+
+ )} +
+
+
+ ); +} diff --git a/ui/goose2/src/features/sessions/ui/__tests__/SessionCard.test.tsx b/ui/goose2/src/features/sessions/ui/__tests__/SessionCard.test.tsx new file mode 100644 index 0000000000..60027039ea --- /dev/null +++ b/ui/goose2/src/features/sessions/ui/__tests__/SessionCard.test.tsx @@ -0,0 +1,89 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { SessionCard } from "../SessionCard"; + +describe("SessionCard", () => { + const defaultProps = { + id: "s1", + title: "Fix sidebar bug", + updatedAt: new Date().toISOString(), + messageCount: 12, + onSelect: vi.fn(), + }; + + it("renders title and message count", () => { + render(); + + expect(screen.getByText("Fix sidebar bug")).toBeInTheDocument(); + expect(screen.getByText("12")).toBeInTheDocument(); + }); + + it("renders persona name when provided", () => { + render(); + + expect(screen.getByText("Code Assistant")).toBeInTheDocument(); + }); + + it("renders project name with color dot when provided", () => { + render( + , + ); + + expect(screen.getByText("My Project")).toBeInTheDocument(); + }); + + it("calls onSelect when clicked", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + + render(); + + await user.click(screen.getByLabelText("Open Fix sidebar bug")); + + expect(onSelect).toHaveBeenCalledWith("s1"); + }); + + it("shows rename and archive in menu for active sessions", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + screen.getByRole("button", { name: /options for fix sidebar bug/i }), + ); + + expect( + screen.getByRole("menuitem", { name: /rename/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: /archive/i }), + ).toBeInTheDocument(); + }); + + it("shows restore option for archived sessions", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + screen.getByRole("button", { name: /options for fix sidebar bug/i }), + ); + + expect( + screen.getByRole("menuitem", { name: /restore/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/ui/goose2/src/features/sidebar/ui/Sidebar.tsx b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx index 9ce16b86e9..a2504d991d 100644 --- a/ui/goose2/src/features/sidebar/ui/Sidebar.tsx +++ b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx @@ -3,7 +3,7 @@ import { IconLayoutSidebar, IconLayoutSidebarFilled, } from "@tabler/icons-react"; -import { BookOpen, Bot, Home } from "lucide-react"; +import { BookOpen, Bot, History, Home, Search } from "lucide-react"; import { GooseIcon } from "@/shared/ui/icons/GooseIcon"; import { cn } from "@/shared/lib/cn"; import type { AppView } from "@/app/AppShell"; @@ -11,6 +11,9 @@ import type { ProjectInfo } from "@/features/projects/api/projects"; import { useChatStore } from "@/features/chat/stores/chatStore"; import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; import { isSessionRunning } from "@/features/chat/lib/sessionActivity"; +import { filterSessions } from "@/features/sessions/lib/filterSessions"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; +import { useProjectStore } from "@/features/projects/stores/projectStore"; import { Button } from "@/shared/ui/button"; import { SidebarProjectsSection } from "./SidebarProjectsSection"; import { useSidebarHighlight } from "./useSidebarHighlight"; @@ -39,6 +42,7 @@ interface SidebarProps { const NAV_ITEMS: readonly { id: AppView; label: string; icon: typeof Bot }[] = [ { id: "agents", label: "Personas", icon: Bot }, { id: "skills", label: "Skills", icon: BookOpen }, + { id: "session-history", label: "Session History", icon: History }, ]; const EXPANDED_PROJECTS_STORAGE_KEY = "goose:sidebar:expanded-projects"; @@ -63,6 +67,8 @@ export function Sidebar({ projects, }: SidebarProps) { const [expanded, setExpanded] = useState(!collapsed); + const [sidebarSearch, setSidebarSearch] = useState(""); + const searchInputRef = useRef(null); const prevCollapsed = useRef(collapsed); const [expandedProjects, setExpandedProjects] = useState< Record @@ -146,6 +152,62 @@ export function Sidebar({ return { byProject, standalone: limitedStandalone }; })(); + const agentStoreState = useAgentStore(); + const projectStoreState = useProjectStore(); + + const sidebarResolvers = { + getPersonaName: (personaId: string) => + agentStoreState.getPersonaById(personaId)?.displayName, + getProjectName: (projectId: string) => + projectStoreState.projects.find((p: { id: string }) => p.id === projectId) + ?.name, + }; + + const filteredProjectSessions = (() => { + if (!sidebarSearch.trim()) return projectSessions; + + const allSessionItems = [ + ...Object.values(projectSessions.byProject).flat(), + ...projectSessions.standalone, + ]; + const matchingIds = new Set( + filterSessions( + allSessionItems.map((item) => ({ + id: item.id, + title: item.title, + projectId: item.projectId, + createdAt: item.updatedAt, + updatedAt: item.updatedAt, + messageCount: 0, + })), + sidebarSearch, + sidebarResolvers, + ).map((s) => s.id), + ); + + const filteredByProject: Record = + {}; + for (const [projectId, items] of Object.entries( + projectSessions.byProject, + )) { + const matching = items.filter((item) => matchingIds.has(item.id)); + const projectNameMatches = sidebarResolvers + .getProjectName(projectId) + ?.toLowerCase() + .includes(sidebarSearch.trim().toLowerCase()); + if (matching.length > 0 || projectNameMatches) { + filteredByProject[projectId] = matching.length > 0 ? matching : items; + } + } + + return { + byProject: filteredByProject, + standalone: projectSessions.standalone.filter((item) => + matchingIds.has(item.id), + ), + }; + })(); + // Auto-expand the project containing the active session useEffect(() => { if (!activeSessionId) return; @@ -185,6 +247,17 @@ export function Sidebar({ }); }, [projects]); + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "k" && e.metaKey) { + e.preventDefault(); + searchInputRef.current?.focus(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + const toggleProject = (projectId: string) => { setExpandedProjects((prev) => ({ ...prev, @@ -303,11 +376,8 @@ export function Sidebar({ )} - {/* TODO: Search bar — uncomment when onSearchClick is wired up */} - {/* */} + {!collapsed && ( + <> + setSidebarSearch(e.target.value)} + placeholder="Search..." + className={cn( + "bg-transparent border-none outline-none text-xs flex-1 min-w-0 placeholder:text-muted-foreground", + labelTransition, + labelVisible + ? "opacity-100 w-auto" + : "opacity-0 w-0 overflow-hidden", + )} + onClick={(e) => e.stopPropagation()} + /> + + ⌘K + + + )} + {/* Home */}