mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
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:
@@ -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.",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface Session {
|
||||
updatedAt: string;
|
||||
archivedAt?: string;
|
||||
messageCount: number;
|
||||
userSetName?: boolean;
|
||||
}
|
||||
|
||||
// SSE event types (from goosed server)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user