feat: implement sessions sidebar

This commit is contained in:
d-kimsuon
2025-08-30 16:00:10 +09:00
parent 6cbb7fba7c
commit 14b074c03c
8 changed files with 195 additions and 52 deletions

View File

@@ -11,5 +11,7 @@ export const useProject = (projectId: string) => {
return await response.json();
},
refetchOnReconnect: true,
refetchInterval: 10 * 1000,
});
};

View File

@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { firstCommandToTitle } from "../../../services/firstCommandToTitle";
import { useSession } from "../hooks/useSession";
import { ConversationList } from "./conversationList/ConversationList";
import { SessionSidebar } from "./sessionSidebar/SessionSidebar";
export const SessionPageContent: FC<{
projectId: string;
@@ -18,37 +19,43 @@ export const SessionPageContent: FC<{
);
return (
<div className="container mx-auto px-4 py-8">
<header className="mb-8">
<Button asChild variant="ghost" className="mb-4">
<Link
href={`/projects/${projectId}`}
className="flex items-center gap-2"
>
<ArrowLeftIcon className="w-4 h-4" />
Back to Session List
</Link>
</Button>
<div className="flex h-screen">
<SessionSidebar currentSessionId={sessionId} projectId={projectId} />
<div className="flex items-center gap-3 mb-2">
<MessageSquareIcon className="w-6 h-6" />
<h1 className="text-3xl font-bold">
{session.meta.firstCommand !== null
? firstCommandToTitle(session.meta.firstCommand)
: sessionId}
</h1>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="max-w-none px-6 md:px-8 py-6 md:py-8 flex-1 overflow-y-auto">
<header className="mb-8">
<Button asChild variant="ghost" className="mb-4">
<Link
href={`/projects/${projectId}`}
className="flex items-center gap-2"
>
<ArrowLeftIcon className="w-4 h-4" />
Back to Session List
</Link>
</Button>
<div className="flex items-center gap-3 mb-2">
<MessageSquareIcon className="w-6 h-6" />
<h1 className="text-3xl font-bold">
{session.meta.firstCommand !== null
? firstCommandToTitle(session.meta.firstCommand)
: sessionId}
</h1>
</div>
<p className="text-muted-foreground font-mono text-sm">
Session ID: {sessionId}
</p>
</header>
<main className="w-full px-20">
<ConversationList
conversations={conversations}
getToolResult={getToolResult}
/>
</main>
</div>
<p className="text-muted-foreground font-mono text-sm">
Session ID: {sessionId}
</p>
</header>
<main>
<ConversationList
conversations={conversations}
getToolResult={getToolResult}
/>
</main>
</div>
</div>
);
};

View File

@@ -51,28 +51,22 @@ export const ConversationList: FC<ConversationListProps> = ({
/>
);
const isSidechain =
conversation.type !== "summary" && conversation.isSidechain;
return [
<li
className={`w-full flex ${
conversation.type === "user"
? "justify-end"
: conversation.type === "assistant"
? "justify-start"
: "justify-center"
isSidechain ||
conversation.type === "assistant" ||
conversation.type === "system" ||
conversation.type === "summary"
? "justify-start"
: "justify-end"
}`}
key={getConversationKey(conversation)}
>
<div
className={`${
conversation.type === "user"
? "w-[90%]"
: conversation.type === "assistant"
? "w-[90%]"
: "w-[100%]"
}`}
>
{elm}
</div>
<div className="w-[85%]">{elm}</div>
</li>,
];
})}

View File

@@ -63,7 +63,11 @@ export const SidechainConversationModal: FC<
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="w-full mb-3">
<Button
variant="outline"
size="sm"
className="w-full mb-3 items-center justify-start"
>
<div className="flex items-center gap-2 overflow-hidden">
<Eye className="h-4 w-4 flex-shrink-0" />
<span className="overflow-hidden text-ellipsis">

View File

@@ -0,0 +1,134 @@
"use client";
import { MessageSquareIcon, PanelLeftIcon } from "lucide-react";
import Link from "next/link";
import { type FC, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import type { Session } from "../../../../../../../server/service/types";
import { useProject } from "../../../../hooks/useProject";
import { firstCommandToTitle } from "../../../../services/firstCommandToTitle";
const SidebarContent: FC<{
sessions: Session[];
currentSessionId: string;
projectId: string;
}> = ({ sessions, currentSessionId, projectId }) => (
<div className="h-full flex flex-col bg-sidebar text-sidebar-foreground">
<div className="border-b border-sidebar-border p-4">
<h2 className="font-semibold text-lg">Sessions</h2>
<p className="text-xs text-sidebar-foreground/70 mt-1">
{sessions.length} total
</p>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-0.5">
{sessions.map((session) => {
const isActive = session.id === currentSessionId;
const title =
session.meta.firstCommand !== null
? firstCommandToTitle(session.meta.firstCommand)
: session.id;
return (
<Link
key={session.id}
href={`/projects/${projectId}/sessions/${encodeURIComponent(
session.id,
)}`}
className={cn(
"block rounded-lg p-2.5 transition-all duration-200 hover:bg-blue-50/60 hover:border-blue-300/60 hover:shadow-sm border border-sidebar-border/40 bg-sidebar/30",
isActive &&
"bg-blue-100 border-blue-400 shadow-md ring-1 ring-blue-200/50 hover:bg-blue-100 hover:border-blue-400",
)}
>
<div className="space-y-1.5">
<h3 className="text-sm font-medium line-clamp-2 leading-tight text-sidebar-foreground">
{title}
</h3>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-xs text-sidebar-foreground/70">
<MessageSquareIcon className="w-3 h-3" />
<span>{session.meta.messageCount}</span>
</div>
{session.meta.lastModifiedAt && (
<span className="text-xs text-sidebar-foreground/60">
{new Date(session.meta.lastModifiedAt).toLocaleDateString(
"en-US",
{
month: "short",
day: "numeric",
},
)}
</span>
)}
</div>
</div>
</Link>
);
})}
</div>
</div>
);
export const SessionSidebar: FC<{
currentSessionId: string;
projectId: string;
className?: string;
}> = ({ currentSessionId, projectId, className }) => {
const {
data: { sessions },
} = useProject(projectId);
const [isCollapsed, setIsCollapsed] = useState(false);
return (
<Collapsible
open={!isCollapsed}
onOpenChange={(open) => setIsCollapsed(!open)}
className={cn("hidden md:flex h-full", className)}
>
<div className="relative h-full">
<div
className={cn(
"h-full border-r border-sidebar-border transition-all duration-300 ease-in-out",
isCollapsed ? "w-12" : "w-72 lg:w-80",
)}
>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="icon"
className="absolute -right-3 top-4 z-10 bg-background border border-sidebar-border shadow-sm"
>
<PanelLeftIcon
className={cn(
"w-4 h-4 transition-transform duration-200",
isCollapsed && "rotate-180",
)}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="h-full data-[state=closed]:animate-slide-out-to-left data-[state=open]:animate-slide-in-from-left">
<SidebarContent
sessions={sessions}
currentSessionId={currentSessionId}
projectId={projectId}
/>
</CollapsibleContent>
{isCollapsed && (
<div className="h-full bg-sidebar border-r border-sidebar-border flex flex-col items-center pt-16">
<MessageSquareIcon className="w-5 h-5 text-sidebar-foreground/70" />
</div>
)}
</div>
</div>
</Collapsible>
);
};

View File

@@ -96,7 +96,7 @@ export const getSessionMeta = async (
messageCount: lines.length,
firstCommand: getFirstCommand(jsonlFilePath, lines),
lastModifiedAt: lastModifiedUnixTime
? new Date(lastModifiedUnixTime)
? new Date(lastModifiedUnixTime).toISOString()
: null,
};

View File

@@ -5,6 +5,11 @@ import { decodeProjectId } from "../project/id";
import type { Session } from "../types";
import { getSessionMeta } from "./getSessionMeta";
const getTime = (date: string | null) => {
if (date === null) return 0;
return new Date(date).getTime();
};
export const getSessions = async (
projectId: string,
): Promise<{ sessions: Session[] }> => {
@@ -27,10 +32,7 @@ export const getSessions = async (
return {
sessions: sessions.sort((a, b) => {
return (
(b.meta.lastModifiedAt?.getTime() ?? 0) -
(a.meta.lastModifiedAt?.getTime() ?? 0)
);
return getTime(b.meta.lastModifiedAt) - getTime(a.meta.lastModifiedAt);
}),
};
};

View File

@@ -23,7 +23,7 @@ export type Session = {
export type SessionMeta = {
messageCount: number;
firstCommand: ParsedCommand | null;
lastModifiedAt: Date | null;
lastModifiedAt: string | null;
};
export type SessionDetail = Session & {