add session history, search, and smart naming (#113)

* docs: add session management design spec

Covers three features: Session History view, search-as-you-type,
and smart session naming. Defers import/export, fork, and
multi-window.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add session management implementation plan

12-task plan covering smart naming, session history view,
and sidebar search. TDD approach with exact file paths and code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add userSetName field to session model

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add useSessionAutoTitle hook for AI session naming

Watches for the streaming → idle transition when messageCount === 2 to
auto-generate a session title via the backend LLM. Falls back to the
first 50 characters of the user's message on API failure. Skips sessions
where the user has manually set a title (userSetName).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: wire auto-title hook into ChatView

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add groupSessionsByDate utility

Groups chat sessions into date-labeled buckets (Today, Yesterday, or
formatted date) sorted newest-first within each group. Uses UTC-based
day boundaries for consistent behavior across timezones.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add filterSessions utility for search-as-you-type

Case-insensitive text filter that matches against session title,
resolved persona name, resolved project name, and formatted date.
Accepts resolver functions to decouple from store dependencies.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add SessionCard component

Presentational card component for displaying session metadata (title,
relative date, message count, persona, project with color dot, working
directory, and archived state). Includes 5 passing tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add SessionHistoryView page component

Full-page session history view with date-grouped cards, search
filtering, archived session loading, and empty states. Composes
SessionCard, SearchBar, groupSessionsByDate, and filterSessions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add Session History route and sidebar nav item

Add "session-history" to AppView type, render SessionHistoryView in
AppShell, and add Session History nav item with History icon to
the sidebar navigation. Bump AppShell file-size limit to 575.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add search-as-you-type to sidebar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prevent infinite re-render in SessionHistoryView

Zustand selector returning .filter() created a new array reference
on every render, causing React useSyncExternalStore to loop.
Memoize the filtered result instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add card actions and separate archived sessions view

- SessionCard now has dropdown menu with Rename/Archive (or Restore
  for archived sessions), inline title editing on rename
- Archived sessions moved behind an "Archived" toggle button instead
  of being mixed in with active sessions
- SessionHistoryView wired to AppShell's rename and archive handlers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use consistent ghost variant for archived toggle button

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use normal font weight on archived toggle button

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add ghost-light button variant with normal font weight

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add right padding to session card title to clear menu button

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: remove spec and plan docs from PR

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
tulsi
2026-04-07 11:47:51 -07:00
committed by GitHub
parent 027aa3f7f2
commit 00b167be07
17 changed files with 1140 additions and 34 deletions
+11 -2
View File
@@ -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.",
},
};
+20 -2
View File
@@ -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 <AgentsView />;
case "projects":
return <ProjectsView onStartChat={handleStartChatFromProject} />;
case "session-history":
return (
<SessionHistoryView
onSelectSession={handleSelectSession}
onRenameChat={handleRenameChat}
onArchiveChat={handleArchiveChat}
/>
);
case "chat":
case "home":
return activeSession ? (
@@ -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<ChatSessionStore>((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<ChatSessionStore>((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);
@@ -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,
);
@@ -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<typeof import("@/shared/api/chat")>();
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",
);
});
});
});
@@ -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<string | null>(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]);
}
@@ -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<ChatSession> & { 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");
});
});
@@ -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),
);
}
@@ -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"]);
});
});
@@ -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<string, ChatSession[]>();
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) ?? [],
}));
}
@@ -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<HTMLInputElement>(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 (
<div
className={cn(
"group relative flex flex-col gap-2 rounded-lg border border-border bg-background p-4 text-left transition-shadow hover:shadow-card",
archivedAt && "opacity-60",
)}
>
{/* Click-to-open overlay */}
<button
type="button"
onClick={() => onSelect?.(id)}
className="absolute inset-0 z-0 rounded-lg"
aria-label={`Open ${title}`}
/>
{/* Title — editable or static */}
{editing ? (
<Input
ref={inputRef}
type="text"
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
onBlur={commitRename}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitRename();
}
if (e.key === "Escape") {
e.preventDefault();
cancelRename();
}
}}
className="relative z-10 text-sm font-medium"
/>
) : (
<p className="text-sm font-medium line-clamp-2 break-words pr-6">
{title}
</p>
)}
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<Calendar className="size-3 shrink-0" />
<span>{formatRelativeDate(updatedAt)}</span>
</div>
<div className="flex items-center gap-1.5">
<MessageSquare className="size-3 shrink-0" />
<span>{messageCount}</span>
</div>
{personaName && (
<div className="flex items-center gap-1.5">
<Bot className="size-3 shrink-0" />
<span className="truncate">{personaName}</span>
</div>
)}
{projectName && (
<div className="flex items-center gap-1.5">
<span
className="inline-block size-2 shrink-0 rounded-full"
style={
projectColor ? { backgroundColor: projectColor } : undefined
}
/>
<span className="truncate">{projectName}</span>
</div>
)}
{workingDir && (
<div className="flex items-center gap-1.5">
<Folder className="size-3 shrink-0" />
<span className="truncate">{workingDir}</span>
</div>
)}
</div>
{/* Actions menu */}
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`Options for ${title}`}
onClick={(e) => e.stopPropagation()}
className={cn(
"absolute right-2 top-2 z-10 size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent/50",
menuOpen
? "visible opacity-100"
: "invisible group-hover:visible opacity-0 group-hover:opacity-100",
)}
>
<MoreHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={4}>
{archivedAt ? (
<DropdownMenuItem
onClick={() => {
setMenuOpen(false);
onUnarchive?.(id);
}}
>
<ArchiveRestore className="size-3.5" />
Restore
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem onClick={startRename}>
<Pencil className="size-3.5" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setMenuOpen(false);
onArchive?.(id);
}}
>
<Trash2 className="size-3.5" />
Archive
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
@@ -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<ChatSession[]>([]);
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 (
<div className="flex flex-1 flex-col h-full min-h-0">
<div className="flex-1 overflow-y-auto min-h-0">
<div className="max-w-5xl mx-auto w-full px-6 py-8 space-y-5 page-transition">
{/* Header */}
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-lg font-semibold font-display tracking-tight">
{showArchived ? "Archived Sessions" : "Session History"}
</h1>
<p className="text-xs text-muted-foreground">
{showArchived
? "Sessions you've archived"
: "Browse and search past sessions"}
</p>
</div>
<Button
type="button"
variant="ghost-light"
size="xs"
onClick={() => {
setShowArchived((prev) => !prev);
setSearch("");
}}
>
<Archive className="size-3.5" />
{showArchived ? "Back to active" : "Archived"}
</Button>
</div>
{/* Search */}
<SearchBar
value={search}
onChange={setSearch}
placeholder={
showArchived
? "Search archived sessions..."
: "Search sessions by title, persona, or project..."
}
/>
{/* Session cards grouped by date */}
{dateGroups.length > 0 &&
dateGroups.map((group) => (
<div key={group.label} className="space-y-2">
<h2 className="text-sm font-medium text-muted-foreground sticky top-0 bg-background py-1 z-10">
{group.label}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{group.sessions.map((session) => (
<SessionCard
key={session.id}
id={session.id}
title={session.title}
updatedAt={session.updatedAt}
messageCount={session.messageCount}
personaName={
session.personaId
? getPersonaName(session.personaId)
: undefined
}
projectName={
session.projectId
? getProjectName(session.projectId)
: undefined
}
projectColor={
session.projectId
? getProjectColor(session.projectId)
: undefined
}
workingDir={
session.projectId
? getWorkingDir(session.projectId)
: undefined
}
archivedAt={session.archivedAt}
onSelect={onSelectSession}
onRename={onRenameChat}
onArchive={handleArchive}
onUnarchive={handleUnarchive}
/>
))}
</div>
</div>
))}
{/* Empty state */}
{dateGroups.length === 0 && (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-muted-foreground">
<History className="h-10 w-10 opacity-30" />
<div className="text-center">
<p className="text-sm font-medium">
{displaySessions.length === 0
? emptyLabel
: "No matching sessions"}
</p>
<p className="text-xs text-muted-foreground mt-1">
{displaySessions.length === 0
? emptyHint
: "Try a different search term."}
</p>
</div>
</div>
)}
</div>
</div>
</div>
);
}
@@ -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(<SessionCard {...defaultProps} />);
expect(screen.getByText("Fix sidebar bug")).toBeInTheDocument();
expect(screen.getByText("12")).toBeInTheDocument();
});
it("renders persona name when provided", () => {
render(<SessionCard {...defaultProps} personaName="Code Assistant" />);
expect(screen.getByText("Code Assistant")).toBeInTheDocument();
});
it("renders project name with color dot when provided", () => {
render(
<SessionCard
{...defaultProps}
projectName="My Project"
projectColor="#3b82f6"
/>,
);
expect(screen.getByText("My Project")).toBeInTheDocument();
});
it("calls onSelect when clicked", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(<SessionCard {...defaultProps} onSelect={onSelect} />);
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(
<SessionCard {...defaultProps} onRename={vi.fn()} onArchive={vi.fn()} />,
);
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(
<SessionCard
{...defaultProps}
archivedAt="2026-04-01T00:00:00Z"
onUnarchive={vi.fn()}
/>,
);
await user.click(
screen.getByRole("button", { name: /options for fix sidebar bug/i }),
);
expect(
screen.getByRole("menuitem", { name: /restore/i }),
).toBeInTheDocument();
});
});
+108 -30
View File
@@ -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<HTMLInputElement>(null);
const prevCollapsed = useRef(collapsed);
const [expandedProjects, setExpandedProjects] = useState<
Record<string, boolean>
@@ -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<string, typeof projectSessions.standalone> =
{};
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({
</button>
)}
{/* TODO: Search bar — uncomment when onSearchClick is wired up */}
{/* <button
type="button"
onClick={onSearchClick}
title={collapsed ? "Search ⌘K" : undefined}
{/* Search bar */}
<div
className={cn(
"flex items-center w-full rounded-md transition-all duration-300 ease-out",
collapsed
@@ -316,29 +386,37 @@ export function Sidebar({
)}
>
<Search className="size-3.5 flex-shrink-0" />
<span
className={cn(
"whitespace-nowrap",
labelTransition,
labelVisible
? "opacity-100 w-auto flex-1 text-left"
: "opacity-0 w-0 overflow-hidden",
)}
>
Search...
</span>
<kbd
className={cn(
"text-[10px] text-muted-foreground px-1 py-0.5 rounded font-mono flex-shrink-0",
labelTransition,
labelVisible
? "opacity-100 w-auto"
: "opacity-0 w-0 overflow-hidden px-0",
)}
>
⌘K
</kbd>
</button> */}
{!collapsed && (
<>
<input
ref={searchInputRef}
type="search"
value={sidebarSearch}
onChange={(e) => 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()}
/>
<kbd
className={cn(
"text-[10px] text-muted-foreground px-1 py-0.5 rounded font-mono flex-shrink-0",
labelTransition,
labelVisible
? "opacity-100 w-auto"
: "opacity-0 w-0 overflow-hidden px-0",
)}
>
⌘K
</kbd>
</>
)}
</div>
{/* Home */}
<button
@@ -451,7 +529,7 @@ export function Sidebar({
{/* Projects + Chats section */}
<SidebarProjectsSection
projects={projects}
projectSessions={projectSessions}
projectSessions={filteredProjectSessions}
expandedProjects={expandedProjects}
toggleProject={toggleProject}
collapsed={collapsed}
+5
View File
@@ -38,6 +38,7 @@ export async function updateSession(
personaId?: string;
modelName?: string;
projectId?: string | null;
userSetName?: boolean;
},
): Promise<void> {
return invoke("update_session", { sessionId, update });
@@ -54,3 +55,7 @@ export async function archiveSession(sessionId: string): Promise<void> {
export async function unarchiveSession(sessionId: string): Promise<void> {
return invoke("unarchive_session", { sessionId });
}
export async function generateSessionTitle(sessionId: string): Promise<string> {
return invoke("generate_session_title", { sessionId });
}
+1
View File
@@ -60,6 +60,7 @@ export interface Session {
updatedAt: string;
archivedAt?: string;
messageCount: number;
userSetName?: boolean;
}
// SSE event types (from goosed server)
+2
View File
@@ -18,6 +18,8 @@ const buttonVariants = cva(
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
"ghost-light":
"font-normal hover:bg-accent hover:text-accent-foreground",
toolbar:
"justify-start bg-transparent font-normal text-foreground shadow-none hover:bg-accent hover:text-accent-foreground active:bg-accent active:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground aria-expanded:bg-accent aria-expanded:text-accent-foreground",
link: "text-brand underline-offset-4 hover:underline",