Skip to content

Commit 44c3498

Browse files
committed
Add session management features to AIChat and ToolsMenu components
- Introduce session handling capabilities in AIChat, allowing users to load, fetch, and rename chat sessions. - Update ToolsMenu to integrate new session management functions. - Enhance useAI hook to support session-related operations and message handling. - Define new AISession type and related message structures for session management.
1 parent 93af0e7 commit 44c3498

File tree

7 files changed

+411
-38
lines changed

7 files changed

+411
-38
lines changed

packages/ui/spa/components/AIChat.tsx

Lines changed: 173 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ import {
2828
User,
2929
Clock,
3030
MapPin,
31+
History,
32+
ChevronLeft,
3133
} from "lucide-react";
34+
import type { AISession } from "../hooks/useStatus";
3235

3336
// ---------------------------------------------------------------------------
3437
// Types
@@ -92,6 +95,16 @@ export type AIChatProps = {
9295
className?: string;
9396
/** Whether the underlying WebSocket connection is ready */
9497
isConnected: boolean;
98+
/** List of past sessions (fetched on demand) */
99+
sessions?: AISession[];
100+
/** The currently active session ID */
101+
currentSessionId?: string;
102+
/** Called to load a previous session */
103+
onLoadSession?: (sessionId: string) => void;
104+
/** Called to trigger a sessions fetch */
105+
onFetchSessions?: () => void;
106+
/** Called to rename a session */
107+
onSetSessionName?: (sessionId: string, name: string) => void;
95108
/**
96109
* @internal – seed messages for Storybook / testing only.
97110
* Not part of the public API.
@@ -129,6 +142,11 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(function AIChat(
129142
suggestions = DEFAULT_SUGGESTIONS,
130143
className,
131144
isConnected,
145+
sessions,
146+
currentSessionId,
147+
onLoadSession,
148+
onFetchSessions,
149+
onSetSessionName,
132150
initialMessages,
133151
},
134152
ref,
@@ -142,6 +160,11 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(function AIChat(
142160
const [inputValue, setInputValue] = useState("");
143161
const textareaRef = useRef<HTMLTextAreaElement>(null);
144162
const bottomRef = useRef<HTMLDivElement>(null);
163+
const [showSessions, setShowSessions] = useState(false);
164+
const [renamingSessionId, setRenamingSessionId] = useState<string | null>(
165+
null,
166+
);
167+
const [renameValue, setRenameValue] = useState("");
145168

146169
// Derive combined list for rendering
147170
const messages: ChatMessage[] = currentMessage
@@ -391,23 +414,155 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(function AIChat(
391414
return (
392415
<div
393416
className={cn(
394-
"flex flex-col h-full w-full bg-bg-primary text-fg-primary",
417+
"flex flex-col h-full w-full bg-bg-primary text-fg-primary relative overflow-hidden",
395418
className,
396419
)}
397420
>
398-
{/* Header with New Chat button */}
399-
{!isEmpty && onNewSession && (
400-
<div className="shrink-0 flex justify-end p-2 border-b border-border-primary">
401-
<Button
402-
variant="ghost"
403-
size="sm"
404-
onClick={onNewSession}
405-
disabled={isStreaming}
406-
className="text-xs gap-1"
407-
>
408-
<Plus className="h-3 w-3" />
409-
New chat
410-
</Button>
421+
{/* Header with New Chat + History buttons */}
422+
{(!isEmpty || onFetchSessions) && (
423+
<div className="shrink-0 flex justify-between items-center p-2 border-b border-border-primary">
424+
<div>
425+
{onFetchSessions && (
426+
<Button
427+
variant="ghost"
428+
size="sm"
429+
onClick={() => {
430+
onFetchSessions();
431+
setShowSessions(true);
432+
}}
433+
className="text-xs gap-1"
434+
>
435+
<History className="h-3 w-3" />
436+
History
437+
</Button>
438+
)}
439+
</div>
440+
<div>
441+
{!isEmpty && onNewSession && (
442+
<Button
443+
variant="ghost"
444+
size="sm"
445+
onClick={onNewSession}
446+
disabled={isStreaming}
447+
className="text-xs gap-1"
448+
>
449+
<Plus className="h-3 w-3" />
450+
New chat
451+
</Button>
452+
)}
453+
</div>
454+
</div>
455+
)}
456+
457+
{/* Sessions panel overlay */}
458+
{showSessions && (
459+
<div className="absolute inset-0 z-overlay flex flex-col bg-bg-primary">
460+
<div className="shrink-0 flex items-center gap-2 p-2 border-b border-border-primary">
461+
<Button
462+
variant="ghost"
463+
size="icon-sm"
464+
onClick={() => {
465+
setShowSessions(false);
466+
setRenamingSessionId(null);
467+
}}
468+
aria-label="Back to chat"
469+
>
470+
<ChevronLeft className="h-4 w-4" />
471+
</Button>
472+
<span className="text-sm font-medium flex-1">Chat history</span>
473+
{onNewSession && (
474+
<Button
475+
variant="ghost"
476+
size="sm"
477+
onClick={() => {
478+
onNewSession();
479+
setShowSessions(false);
480+
}}
481+
className="text-xs gap-1"
482+
>
483+
<Plus className="h-3 w-3" />
484+
New chat
485+
</Button>
486+
)}
487+
</div>
488+
<ScrollArea className="flex-1 min-h-0">
489+
{!sessions || sessions.length === 0 ? (
490+
<div className="p-6 text-center text-sm text-fg-secondary">
491+
No previous sessions
492+
</div>
493+
) : (
494+
<div className="flex flex-col divide-y divide-border-primary">
495+
{sessions.map((session) => {
496+
const isActive = session.id === currentSessionId;
497+
const isRenaming = renamingSessionId === session.id;
498+
const displayName =
499+
session.name ??
500+
`Chat, ${new Date(session.updatedAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })}`;
501+
return (
502+
<div
503+
key={session.id}
504+
className={cn(
505+
"flex items-center gap-2 px-3 py-2.5 group",
506+
isActive && "bg-bg-secondary",
507+
)}
508+
>
509+
{isRenaming ? (
510+
<input
511+
autoFocus
512+
className="flex-1 text-sm bg-bg-primary border border-border-primary rounded px-2 py-0.5 focus:outline-none focus:ring-1 focus:ring-ring"
513+
value={renameValue}
514+
onChange={(e) => setRenameValue(e.target.value)}
515+
onKeyDown={(e) => {
516+
if (e.key === "Enter") {
517+
const trimmed = renameValue.trim();
518+
if (trimmed && onSetSessionName) {
519+
onSetSessionName(session.id, trimmed);
520+
}
521+
setRenamingSessionId(null);
522+
} else if (e.key === "Escape") {
523+
setRenamingSessionId(null);
524+
}
525+
}}
526+
onBlur={() => {
527+
const trimmed = renameValue.trim();
528+
if (trimmed && onSetSessionName) {
529+
onSetSessionName(session.id, trimmed);
530+
}
531+
setRenamingSessionId(null);
532+
}}
533+
/>
534+
) : (
535+
<button
536+
className="flex-1 text-left text-sm truncate"
537+
onClick={() => {
538+
onLoadSession?.(session.id);
539+
setShowSessions(false);
540+
}}
541+
>
542+
{displayName}
543+
</button>
544+
)}
545+
{!isRenaming && (
546+
<Button
547+
variant="ghost"
548+
size="icon-sm"
549+
className="opacity-0 group-hover:opacity-100 shrink-0"
550+
onClick={(e) => {
551+
e.stopPropagation();
552+
setRenameValue(session.name ?? "");
553+
setRenamingSessionId(session.id);
554+
}}
555+
aria-label="Rename session"
556+
>
557+
<Pencil className="h-3 w-3" />
558+
</Button>
559+
)}
560+
</div>
561+
);
562+
})}
563+
</div>
564+
)}
565+
</ScrollArea>
411566
</div>
412567
)}
413568

@@ -648,6 +803,10 @@ const TOOL_DISPLAY: Record<string, { label: string; icon: React.ReactNode }> = {
648803
label: "Getting current location",
649804
icon: <MapPin className="h-3 w-3" />,
650805
},
806+
set_session_name: {
807+
label: "Naming session",
808+
icon: <Pencil className="h-3 w-3" />,
809+
},
651810
};
652811

653812
function ToolActivitiesIndicator({

packages/ui/spa/components/ToolsMenu.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,16 @@ export function ToolsMenu() {
5858
const committedPatchIds = useCommittedPatches();
5959
const pendingChanges = currentPatchIds.length - committedPatchIds.size;
6060
const chatRef = useRef<AIChatHandle | null>(null);
61-
const { sendMessage, isConnected, newSession } = useAI(chatRef);
61+
const {
62+
sendMessage,
63+
isConnected,
64+
newSession,
65+
sessions,
66+
currentSessionId,
67+
getSessions,
68+
setSessionName,
69+
loadSession,
70+
} = useAI(chatRef);
6271
return (
6372
<div
6473
className="flex flex-col h-[100svh] bg-bg-primary"
@@ -150,6 +159,11 @@ export function ToolsMenu() {
150159
onSendMessage={sendMessage}
151160
onNewSession={newSession}
152161
isConnected={isConnected}
162+
sessions={sessions}
163+
currentSessionId={currentSessionId}
164+
onLoadSession={loadSession}
165+
onFetchSessions={getSessions}
166+
onSetSessionName={setSessionName}
153167
/>
154168
</div>
155169
)}

packages/ui/spa/components/ValProvider.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,21 @@ type ValContextValue = {
112112
};
113113
subscribeToWsMessages: (handler: WsMessageHandler) => () => void;
114114
sendWsMessage: (
115-
data: z.infer<typeof AIPromptMessage> | AIToolResultMessage,
115+
data:
116+
| z.infer<typeof AIPromptMessage>
117+
| AIToolResultMessage
118+
| {
119+
type: "ai_get_sessions";
120+
id: string;
121+
limit?: number;
122+
cursor?: { updatedAt: string; id: string };
123+
}
124+
| {
125+
type: "ai_set_session_name";
126+
id: string;
127+
sessionId: string;
128+
name: string;
129+
},
116130
) => boolean;
117131
isWsConnected: boolean;
118132
};

0 commit comments

Comments
 (0)