@djangocfg/widget-assistant 0.2.9 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ 'use client';
2
+
3
+ import { Check, History, Plus } from 'lucide-react';
4
+
5
+ import {
6
+ Button,
7
+ DropdownMenu,
8
+ DropdownMenuContent,
9
+ DropdownMenuItem,
10
+ DropdownMenuLabel,
11
+ DropdownMenuTrigger,
12
+ Tooltip,
13
+ TooltipContent,
14
+ TooltipProvider,
15
+ TooltipTrigger,
16
+ } from '@djangocfg/ui-core/components';
17
+
18
+ import { useAssistantCopy, useAssistantLocale } from '../i18n';
19
+ import type { Session } from '../stream/useSession';
20
+ import { relativeTime } from '../stream/sessions';
21
+
22
+ interface Props {
23
+ session: Pick<Session, 'thread' | 'sessions' | 'busy' | 'startNew' | 'switchTo' | 'refreshSessions'>;
24
+ /** The language tag for "5 minutes ago". Absent: the assistant's active language. */
25
+ locale?: string;
26
+ /** Hide the list and keep only "New chat" (a host with no list endpoint, such as an anonymous one). */
27
+ newOnly?: boolean;
28
+ }
29
+
30
+ /**
31
+ * The chat's header controls (put them in the frame's or the dock's `actions`, next to Close; each has a tooltip): "New chat" (a new session; the old ones stay) and the list of earlier sessions, newest first, to switch back.
32
+ * Both wait while an answer streams: Stop ends it first. Words come from the assistant's own dictionaries.
33
+ */
34
+ export function SessionsMenu({ session, locale, newOnly = false }: Props) {
35
+ const copy = useAssistantCopy(locale);
36
+ const active = useAssistantLocale(locale);
37
+ const current = session.thread.kind === 'ready' ? session.thread.session : null;
38
+ return (
39
+ <TooltipProvider delayDuration={300}>
40
+ <div className="flex items-center gap-1" data-testid="sessions-menu">
41
+ <Tooltip>
42
+ <TooltipTrigger asChild>
43
+ <Button variant="ghost" size="icon" aria-label={copy.newChat} disabled={session.busy} onClick={() => void session.startNew()}>
44
+ <Plus className="size-4" aria-hidden="true" />
45
+ </Button>
46
+ </TooltipTrigger>
47
+ <TooltipContent>{copy.newChat}</TooltipContent>
48
+ </Tooltip>
49
+ {newOnly ? null : (
50
+ <DropdownMenu onOpenChange={(open) => open && session.refreshSessions()}>
51
+ <Tooltip>
52
+ <TooltipTrigger asChild>
53
+ <DropdownMenuTrigger asChild>
54
+ <Button variant="ghost" size="icon" aria-label={copy.sessions}>
55
+ <History className="size-4" aria-hidden="true" />
56
+ </Button>
57
+ </DropdownMenuTrigger>
58
+ </TooltipTrigger>
59
+ <TooltipContent>{copy.sessions}</TooltipContent>
60
+ </Tooltip>
61
+ <DropdownMenuContent align="end" sideOffset={4} className="max-h-96 w-72 overflow-y-auto">
62
+ <DropdownMenuLabel>{copy.sessions}</DropdownMenuLabel>
63
+ {session.sessions === null ? null : session.sessions.length === 0 ? (
64
+ <p className="px-2 py-1.5 text-xs text-muted-foreground">{copy.sessionsEmpty}</p>
65
+ ) : (
66
+ session.sessions.map((item) => (
67
+ <DropdownMenuItem key={item.id} disabled={session.busy} onSelect={() => void session.switchTo(item.id)} className="items-start gap-2">
68
+ <Check className={`mt-0.5 size-4 shrink-0 ${item.id === current ? '' : 'invisible'}`} aria-hidden="true" />
69
+ <span className="min-w-0 flex-1">
70
+ <span className="block truncate">{item.title || copy.newChat}</span>
71
+ <span className="block text-xs text-muted-foreground">{relativeTime(item.last_message_at, active)}</span>
72
+ </span>
73
+ </DropdownMenuItem>
74
+ ))
75
+ )}
76
+ </DropdownMenuContent>
77
+ </DropdownMenu>
78
+ )}
79
+ </div>
80
+ </TooltipProvider>
81
+ );
82
+ }
@@ -2,9 +2,7 @@
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react';
4
4
 
5
- import { X } from 'lucide-react';
6
-
7
- import { Button } from '@djangocfg/ui-core/components';
5
+ import { ChatPanelHeader, PANEL_TITLE_CLASS, PanelCloseButton } from '@djangocfg/widget-chat';
8
6
 
9
7
  import { AssistantSlot } from '../Host';
10
8
  import { useAssistantCopy } from '../i18n';
@@ -28,6 +26,8 @@ interface Props {
28
26
  onWidthChange: (width: number) => void;
29
27
  onClose: () => void;
30
28
  labels: FrameLabels;
29
+ /** Header controls beside the close button (for example `SessionsMenu`): one header, a thin divider keeps them apart from Close. */
30
+ actions?: ReactNode;
31
31
  /** What the column shows instead of the chat (the locked view, a notice). Absent: the chat itself. */
32
32
  content?: ReactNode;
33
33
  /** The page. */
@@ -47,7 +47,7 @@ const RESTORE_GRACE_MS = 800;
47
47
  * reflows), with a short fade. `--assistant-w` is a registered length (`layout.css`), so fixed host elements follow the same curve. The column stays mounted until the
48
48
  * close finishes. Resizing by hand and the first paint after a page load are not animated.
49
49
  */
50
- export function AssistantFrame({ docked, width, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content, children }: Props) {
50
+ export function AssistantFrame({ docked, width, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content, actions, children }: Props) {
51
51
  const born = useRef(0);
52
52
  const [mounted, setMounted] = useState(false);
53
53
  const [shown, setShown] = useState(false);
@@ -90,12 +90,12 @@ export function AssistantFrame({ docked, width, minWidth, maxWidth, defaultWidth
90
90
  return (
91
91
  <div className="assistant-frame flex" data-moving={moving ? 'true' : 'false'} style={style}>
92
92
  <div className="min-w-0 flex-1">{children}</div>
93
- {mounted ? <Column {...{ width, shown, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content }} /> : null}
93
+ {mounted ? <Column {...{ width, shown, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content, actions }} /> : null}
94
94
  </div>
95
95
  );
96
96
  }
97
97
 
98
- function Column({ width, shown, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content }: Omit<Props, 'docked' | 'children'> & { shown: boolean }) {
98
+ function Column({ width, shown, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content, actions }: Omit<Props, 'docked' | 'children'> & { shown: boolean }) {
99
99
  const copy = useAssistantCopy();
100
100
  // The handle sits on the column's left edge: the pointer's distance from the window's right edge is the new width.
101
101
  const drag = useCallback(
@@ -140,12 +140,11 @@ function Column({ width, shown, minWidth, maxWidth, defaultWidth, onWidthChange,
140
140
  onDoubleClick={() => onWidthChange(defaultWidth)}
141
141
  className="absolute inset-y-0 -left-1 z-10 w-2 cursor-col-resize touch-none outline-none transition-colors hover:bg-primary/30 focus-visible:bg-primary/40"
142
142
  />
143
- <header className="flex shrink-0 items-center justify-between border-b px-4 py-2.5">
144
- <h2 className="font-display text-sm font-semibold uppercase tracking-wide text-muted-foreground">{labels.title}</h2>
145
- <Button variant="ghost" size="icon" aria-label={labels.close ?? copy.close} onClick={onClose}>
146
- <X className="size-4" aria-hidden="true" />
147
- </Button>
148
- </header>
143
+ <ChatPanelHeader
144
+ heading={<h2 className={PANEL_TITLE_CLASS}>{labels.title}</h2>}
145
+ actions={actions}
146
+ close={<PanelCloseButton label={labels.close ?? copy.close} onClick={onClose} />}
147
+ />
149
148
  {content ? <div className="flex min-h-0 flex-1 flex-col">{content}</div> : <AssistantSlot className="min-h-0 flex-1" />}
150
149
  </aside>
151
150
  </div>
@@ -16,13 +16,15 @@ interface Props {
16
16
  composerLabel?: string;
17
17
  /** Replaces the lock. */
18
18
  icon?: ReactNode;
19
+ /** Makes the composer a button for the way out (sign in): touching the field is the same ask as the action. */
20
+ onComposerClick?: () => void;
19
21
  }
20
22
 
21
23
  /**
22
24
  * The chat as it looks when the viewer cannot write: a centred icon, one short reason, the action, and a composer that is visibly switched off.
23
25
  * The root is a flex item (`flex-1`), not `h-full`: percentage heights do not resolve inside a flex column, and the message collapsed against the header.
24
26
  */
25
- export function AssistantLocked({ title, body, action, composerLabel, icon }: Props) {
27
+ export function AssistantLocked({ title, body, action, composerLabel, icon, onComposerClick }: Props) {
26
28
  const copy = useAssistantCopy();
27
29
  return (
28
30
  <div className="flex min-h-0 flex-1 flex-col">
@@ -35,9 +37,15 @@ export function AssistantLocked({ title, body, action, composerLabel, icon }: Pr
35
37
  {action ? <div className="mt-2">{action}</div> : null}
36
38
  </div>
37
39
  <div className="px-4 pt-2 pb-[max(1rem,env(safe-area-inset-bottom))]">
38
- <div aria-disabled="true" className="flex select-none items-center rounded-2xl border bg-muted/40 px-4 py-3.5 text-sm text-muted-foreground opacity-70">
39
- {composerLabel ?? copy.lockedComposer}
40
- </div>
40
+ {onComposerClick ? (
41
+ <button type="button" onClick={onComposerClick} className="flex w-full items-center rounded-2xl border bg-muted/40 px-4 py-3.5 text-start text-sm text-muted-foreground">
42
+ {composerLabel ?? copy.lockedComposer}
43
+ </button>
44
+ ) : (
45
+ <div aria-disabled="true" className="flex select-none items-center rounded-2xl border bg-muted/40 px-4 py-3.5 text-sm text-muted-foreground opacity-70">
46
+ {composerLabel ?? copy.lockedComposer}
47
+ </div>
48
+ )}
41
49
  </div>
42
50
  </div>
43
51
  );
@@ -2,10 +2,9 @@
2
2
 
3
3
  import type { ReactNode } from 'react';
4
4
 
5
- import { X } from 'lucide-react';
6
-
7
- import { Button, Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, SidePanel } from '@djangocfg/ui-core/components';
5
+ import { SidePanel } from '@djangocfg/ui-core/components';
8
6
  import { useIsMobile } from '@djangocfg/ui-core/hooks';
7
+ import { ChatPanelHeader, ChatSheet, PANEL_TITLE_CLASS, PanelCloseButton } from '@djangocfg/widget-chat';
9
8
 
10
9
  import { useAssistantCopy } from '../i18n';
11
10
 
@@ -19,42 +18,32 @@ interface Props {
19
18
  contextLine?: string;
20
19
  /** Accessible name of the close button on phones. Absent: the bundled word. */
21
20
  closeLabel?: string;
21
+ /** Header controls before the close button (for example `SessionsMenu`). */
22
+ actions?: ReactNode;
22
23
  }
23
24
 
24
- /** The chat when the page has no room for a column: a non-modal side panel on tablets (the page stays usable), a full-screen sheet on phones. */
25
- export function ChatDock({ open, onOpenChange, children: chat, title, contextLine, closeLabel }: Props) {
26
- const copy = useAssistantCopy();
25
+ /** The chat when the page has no room for a column: a non-modal side panel on tablets (the page stays usable), the package's full-screen `ChatSheet` on phones (it owns the keyboard, layer and pinning problems). */
26
+ export function ChatDock({ open, onOpenChange, children: chat, title, contextLine, closeLabel, actions }: Props) {
27
27
  const isMobile = useIsMobile();
28
+ const copy = useAssistantCopy();
28
29
 
29
30
  if (isMobile) {
30
31
  return (
31
- <Drawer open={open} onOpenChange={onOpenChange} direction="bottom">
32
- <DrawerContent height="100dvh" className="max-h-[100dvh] rounded-t-none">
33
- <DrawerHeader className="flex flex-row items-center justify-between border-b py-2 text-left">
34
- <div>
35
- <DrawerTitle>{title}</DrawerTitle>
36
- {contextLine ? <DrawerDescription>{contextLine}</DrawerDescription> : null}
37
- </div>
38
- <DrawerClose asChild>
39
- <Button variant="ghost" size="icon" aria-label={closeLabel ?? copy.close}><X className="size-5" aria-hidden="true" /></Button>
40
- </DrawerClose>
41
- </DrawerHeader>
42
- {/* The drawer wraps its children in a scroller that is as tall as its content (it only stretches with snap points), so `flex-1` cannot fill the sheet:
43
- the body gets the screen height minus the handle, the header and the bottom padding. */}
44
- <div className="flex flex-col" style={{ height: 'calc(100dvh - 7.5rem)' }}>{chat}</div>
45
- </DrawerContent>
46
- </Drawer>
32
+ <ChatSheet open={open} onOpenChange={onOpenChange} title={title} description={contextLine} closeLabel={closeLabel ?? copy.close} actions={actions}>
33
+ {chat}
34
+ </ChatSheet>
47
35
  );
48
36
  }
49
37
 
50
38
  return (
51
39
  <SidePanel open={open} onOpenChange={onOpenChange}>
52
40
  <SidePanel.Content width="min(440px, 90vw)">
53
- <SidePanel.Header>
54
- <SidePanel.Title>{title}</SidePanel.Title>
55
- {contextLine ? <SidePanel.Description>{contextLine}</SidePanel.Description> : null}
56
- <SidePanel.Close />
57
- </SidePanel.Header>
41
+ <ChatPanelHeader
42
+ heading={<SidePanel.Title className={PANEL_TITLE_CLASS}>{title}</SidePanel.Title>}
43
+ description={contextLine ? <SidePanel.Description>{contextLine}</SidePanel.Description> : undefined}
44
+ actions={actions}
45
+ close={<PanelCloseButton label={closeLabel ?? copy.close} onClick={() => onOpenChange(false)} />}
46
+ />
58
47
  <SidePanel.Body>{chat}</SidePanel.Body>
59
48
  </SidePanel.Content>
60
49
  </SidePanel>
@@ -6,6 +6,8 @@ import { ChatHttpError, ChatNetworkError, type ChatTransport } from '../wire/tra
6
6
 
7
7
  export interface AdapterDeps {
8
8
  transport: ChatTransport;
9
+ /** The session this message goes to: the current one, or a new one created now (the first send of a user who has none). A rejection ends the turn as a failed message. Absent: no session (an anonymous host). */
10
+ threadFor?: () => Promise<string>;
9
11
  /** Scope of this turn, read at send time (the page the user is on). */
10
12
  getContext: () => ChatContext | undefined;
11
13
  /** Ephemeral progress for the live label above the composer; `null` when the run ends. Never enters the transcript. */
@@ -17,6 +19,8 @@ export interface AdapterDeps {
17
19
  onUnknownPayload?: (kind: string) => void;
18
20
  /** The stream ended without a terminal frame: the host reconciles from the server. */
19
21
  onInterrupted?: () => void;
22
+ /** A turn is in flight (true at the send, false when its stream ends): the host holds session switching meanwhile. */
23
+ onActive?: (active: boolean) => void;
20
24
  /** A network-level failure, for the connectivity monitor. */
21
25
  onNetworkFailure?: () => void;
22
26
  /** One plain sentence per outcome (the catalogue). */
@@ -56,6 +60,7 @@ export function createStreamAdapter(deps: AdapterDeps): ChatAdapter {
56
60
  let pending = '';
57
61
  let timer: ReturnType<typeof setTimeout> | null = null;
58
62
  let terminal = false;
63
+ deps.onActive?.(true);
59
64
 
60
65
  const open = (id: string) => {
61
66
  messageId = id;
@@ -114,7 +119,8 @@ export function createStreamAdapter(deps: AdapterDeps): ChatAdapter {
114
119
  };
115
120
 
116
121
  try {
117
- for await (const frame of deps.transport.open({ text, clientId, context: deps.getContext(), signal })) {
122
+ const threadId = deps.threadFor ? await deps.threadFor() : undefined;
123
+ for await (const frame of deps.transport.open({ threadId, text, clientId, context: deps.getContext(), signal })) {
118
124
  handle(frame);
119
125
  if (terminal) break;
120
126
  }
@@ -142,6 +148,7 @@ export function createStreamAdapter(deps: AdapterDeps): ChatAdapter {
142
148
  } finally {
143
149
  if (timer) clearTimeout(timer);
144
150
  deps.onStatus(null);
151
+ deps.onActive?.(false);
145
152
  controller.close();
146
153
  }
147
154
  },
@@ -0,0 +1,58 @@
1
+ import type { StoredConversation, StoredThread } from '../wire/stored';
2
+ import { ThreadNotFoundError } from '../wire/transport';
3
+
4
+ /** The session endpoints (`/apix/chat/v1/threads`), supplied by the app. `load` throws `ThreadNotFoundError` for a session that is not the viewer's or is gone. */
5
+ export interface ThreadApi {
6
+ /** The viewer's sessions with messages, newest activity first. */
7
+ list: (signal: AbortSignal) => Promise<StoredThread[]>;
8
+ /** A new, empty session (`POST threads`). */
9
+ create: () => Promise<StoredThread>;
10
+ /** One session with its last messages. */
11
+ load: (id: string, signal: AbortSignal) => Promise<StoredConversation>;
12
+ }
13
+
14
+ /** Where the last used session id is kept (the host picks the key). Reads and writes must never throw. */
15
+ export interface SessionStorage {
16
+ read: () => string | null;
17
+ write: (id: string | null) => void;
18
+ }
19
+
20
+ export const NO_SESSION: StoredConversation = { id: null, messages: [] };
21
+
22
+ /**
23
+ * The session to show on page load: the remembered one, else the newest, else none (a session is created on the first send, never on load).
24
+ * A remembered id that is gone (another account on this browser, a purged session) is forgotten and the newest takes over.
25
+ */
26
+ export async function restoreSession({ api, storage, signal }: { api: ThreadApi; storage?: SessionStorage; signal: AbortSignal }): Promise<StoredConversation> {
27
+ const remembered = storage?.read() ?? null;
28
+ if (remembered) {
29
+ try {
30
+ return await api.load(remembered, signal);
31
+ } catch (error) {
32
+ if (!(error instanceof ThreadNotFoundError)) throw error;
33
+ storage?.write(null);
34
+ }
35
+ }
36
+ const [newest] = await api.list(signal);
37
+ return newest ? api.load(newest.id, signal) : NO_SESSION;
38
+ }
39
+
40
+ const STEPS: ReadonlyArray<[Intl.RelativeTimeFormatUnit, number]> = [
41
+ ['year', 31_536_000],
42
+ ['month', 2_592_000],
43
+ ['week', 604_800],
44
+ ['day', 86_400],
45
+ ['hour', 3_600],
46
+ ['minute', 60],
47
+ ];
48
+
49
+ /** "5 minutes ago" in the viewer's language; under a minute it is `now` (the unit `numeric: 'auto'` words as "now"). */
50
+ export function relativeTime(iso: string, locale: string, now: number = Date.now()): string {
51
+ const seconds = Math.round((Date.parse(iso) - now) / 1000);
52
+ const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
53
+ const abs = Math.abs(seconds);
54
+ for (const [unit, size] of STEPS) {
55
+ if (abs >= size) return formatter.format(Math.round(seconds / size), unit);
56
+ }
57
+ return formatter.format(0, 'second');
58
+ }
@@ -8,17 +8,12 @@ import { useAssistantCopy } from '../i18n';
8
8
  import { sessionCopyFor } from '../i18n/sessionCopy';
9
9
 
10
10
  import type { ChatContext, Phase } from '../wire/frames';
11
- import type { StoredConversation } from '../wire/stored';
12
- import type { ChatHttpError, ChatTransport } from '../wire/transport';
11
+ import type { StoredConversation, StoredThread } from '../wire/stored';
12
+ import { ThreadNotFoundError, type ChatHttpError, type ChatTransport } from '../wire/transport';
13
13
  import { createStreamAdapter, type AdapterDeps } from './adapter';
14
14
  import { fromStoredThread } from './fromStored';
15
15
  import { reconcile } from './reconcile';
16
-
17
- /** The conversation endpoints, supplied by the app (signed-in hosts). */
18
- export interface ThreadApi {
19
- load: (signal: AbortSignal) => Promise<StoredConversation>;
20
- clear: () => Promise<void>;
21
- }
16
+ import { NO_SESSION, restoreSession, type SessionStorage, type ThreadApi } from './sessions';
22
17
 
23
18
  export interface SessionCopy {
24
19
  messages: AdapterDeps['messages'];
@@ -30,19 +25,22 @@ export interface SessionCopy {
30
25
 
31
26
  export interface SessionDeps {
32
27
  transport: ChatTransport;
33
- /** Absent: an anonymous session. Nothing is fetched, the room starts empty, and a lost stream cannot be reconciled (the notice offers Retry). */
28
+ /** Absent: an anonymous session. Nothing is fetched or listed, the room starts empty, "new chat" empties it, and a lost stream cannot be reconciled (the notice offers Retry). */
34
29
  api?: ThreadApi;
30
+ /** Remembers the last used session so a reload returns to it. Absent: the newest session is shown. */
31
+ storage?: SessionStorage;
35
32
  getContext: () => ChatContext | undefined;
36
- /** Absent: the bundled words for the active language. */
37
- copy?: SessionCopy;
38
33
  /** The `kind`s the host draws; any other live payload is dropped and reported. */
39
34
  payloadKinds?: readonly string[];
40
35
  onUnknownPayload?: (kind: string) => void;
36
+ /** Absent: the bundled words for the active language. */
37
+ copy?: SessionCopy;
41
38
  onRejected?: (error: ChatHttpError) => void;
42
39
  onNetworkFailure?: () => void;
43
40
  }
44
41
 
45
- export type ThreadState = { kind: 'loading' } | { kind: 'error' } | { kind: 'ready'; id: string; history: ChatMessage[] };
42
+ /** `id` is the transcript's identity (the session plus a revision: a new one reseeds the widget); `session` is the session itself, `null` before the first send. */
43
+ export type ThreadState = { kind: 'loading' } | { kind: 'error' } | { kind: 'ready'; id: string; session: string | null; history: ChatMessage[] };
46
44
 
47
45
  /** `reconnecting`: the stream was lost and the server is being asked; `stalled`: the answer is still running after the budget (offer Retry). */
48
46
  export type SessionNotice = 'reconnecting' | 'stalled';
@@ -53,14 +51,21 @@ export interface Session {
53
51
  /** Live progress label while a run streams; null otherwise. */
54
52
  statusLabel: string | null;
55
53
  notice: SessionNotice | null;
56
- /** Load the thread again (after a load error or a stalled run). */
54
+ /** Load the session again (after a load error or a stalled run). */
57
55
  reload: () => void;
58
- /** Delete the conversation; the next message starts a new one. */
59
- clear: () => Promise<void>;
56
+ /** The viewer's sessions, newest activity first; `null` until `refreshSessions` has answered once. */
57
+ sessions: StoredThread[] | null;
58
+ refreshSessions: () => void;
59
+ /** A turn is in flight: starting or switching a session waits for it (Stop ends it). */
60
+ busy: boolean;
61
+ /** "New chat": a new empty session, shown at once. A no-op while the current one is empty. Resolves `false` when it failed (nothing changed). */
62
+ startNew: () => Promise<boolean>;
63
+ /** Show another session. Resolves `false` when it failed (the current one stays). */
64
+ switchTo: (id: string) => Promise<boolean>;
60
65
  }
61
66
 
62
67
  /**
63
- * One signed-in conversation: the thread from the server (no client copy), the adapter for new messages, the live status label, and the reconcile
68
+ * The signed-in assistant's sessions: the shown session from the server (no client copy), the adapter for new messages, the live status label, and the reconcile
64
69
  * that runs when a stream is lost. A new `history` identity (`id` carries a revision) reseeds the widget only between runs.
65
70
  */
66
71
  export function useSession(given: SessionDeps): Session {
@@ -71,27 +76,53 @@ export function useSession(given: SessionDeps): Session {
71
76
  const [thread, setThread] = useState<ThreadState>({ kind: 'loading' });
72
77
  const [phase, setPhase] = useState<Phase | null>(null);
73
78
  const [notice, setNotice] = useState<SessionNotice | null>(null);
79
+ const [sessions, setSessions] = useState<StoredThread[] | null>(null);
80
+ const [busy, setBusy] = useState(false);
74
81
  const revision = useRef(0);
75
82
  const live = useRef<AbortController | null>(null);
83
+ /** The shown session's id; `null` before the first send. Written with the storage, in one place. */
84
+ const current = useRef<string | null>(null);
76
85
 
77
- const seed = useCallback((conversation: StoredConversation) => {
78
- revision.current += 1;
79
- setThread({ kind: 'ready', id: `${conversation.id ?? 'new'}:${revision.current}`, history: fromStoredThread(conversation.messages, ref.current.copy.failedText) });
86
+ const remember = useCallback((id: string | null) => {
87
+ current.current = id;
88
+ ref.current.storage?.write(id);
80
89
  }, []);
81
90
 
91
+ const seed = useCallback(
92
+ (conversation: StoredConversation) => {
93
+ revision.current += 1;
94
+ remember(conversation.id);
95
+ setThread({
96
+ kind: 'ready',
97
+ id: `${conversation.id ?? 'new'}:${revision.current}`,
98
+ session: conversation.id,
99
+ history: fromStoredThread(conversation.messages, ref.current.copy.failedText),
100
+ });
101
+ },
102
+ [remember],
103
+ );
104
+
82
105
  const load = useCallback(() => {
83
106
  live.current?.abort();
84
107
  const controller = new AbortController();
85
108
  live.current = controller;
86
- setNotice(null);
87
- const api = ref.current.api;
88
- if (!api) return seed({ id: null, messages: [] });
89
109
  setThread({ kind: 'loading' });
90
- api.load(controller.signal).then(
110
+ setNotice(null);
111
+ const { api, storage } = ref.current;
112
+ if (!api) return seed(NO_SESSION);
113
+ const restore = () => restoreSession({ api, storage, signal: controller.signal });
114
+ const read = current.current
115
+ ? api.load(current.current, controller.signal).catch((error: unknown) => {
116
+ if (!(error instanceof ThreadNotFoundError)) throw error;
117
+ remember(null); // the shown session is gone: fall back to the newest
118
+ return restore();
119
+ })
120
+ : restore();
121
+ read.then(
91
122
  (conversation) => !controller.signal.aborted && seed(conversation),
92
123
  () => !controller.signal.aborted && setThread({ kind: 'error' }),
93
124
  );
94
- }, [seed]);
125
+ }, [seed, remember]);
95
126
 
96
127
  useEffect(() => {
97
128
  load();
@@ -102,11 +133,25 @@ export function useSession(given: SessionDeps): Session {
102
133
  () =>
103
134
  createStreamAdapter({
104
135
  transport: deps.transport,
136
+ // The first send of a viewer with no session creates one; the shown transcript is not reseeded (that would drop the live message).
137
+ threadFor: ref.current.api
138
+ ? async () => {
139
+ if (current.current) return current.current;
140
+ const created = await ref.current.api!.create();
141
+ remember(created.id);
142
+ return created.id;
143
+ }
144
+ : undefined,
105
145
  getContext: () => ref.current.getContext(),
106
146
  onStatus: setPhase,
107
147
  payloadKinds: deps.payloadKinds,
108
148
  onUnknownPayload: (kind) => ref.current.onUnknownPayload?.(kind),
109
- onRejected: (error) => ref.current.onRejected?.(error),
149
+ onActive: setBusy,
150
+ onRejected: (error) => {
151
+ // The session vanished (purged, or another account's): forget it, the next send starts a fresh one.
152
+ if (error.code === 'not_found') remember(null);
153
+ ref.current.onRejected?.(error);
154
+ },
110
155
  onNetworkFailure: () => ref.current.onNetworkFailure?.(),
111
156
  messages: {
112
157
  rejected: (error) => ref.current.copy.messages.rejected(error),
@@ -116,12 +161,12 @@ export function useSession(given: SessionDeps): Session {
116
161
  },
117
162
  },
118
163
  onInterrupted: () => {
119
- setNotice('reconnecting');
120
164
  const api = ref.current.api;
121
165
  if (!api) return setNotice('stalled');
166
+ setNotice('reconnecting');
122
167
  const controller = new AbortController();
123
168
  live.current = controller;
124
- reconcile({ fetchConversation: api.load, signal: controller.signal }).then(
169
+ reconcile({ fetchConversation: (signal) => api.load(current.current ?? '', signal), signal: controller.signal }).then(
125
170
  (result) => {
126
171
  if (controller.signal.aborted) return;
127
172
  if (result.outcome === 'timeout') return setNotice('stalled');
@@ -133,14 +178,46 @@ export function useSession(given: SessionDeps): Session {
133
178
  },
134
179
  }),
135
180
  // The transport is one per mounted chat; everything else is read through `ref`.
136
- [deps.transport, deps.payloadKinds, seed],
181
+ // eslint-disable-next-line react-hooks/exhaustive-deps
182
+ [deps.transport, deps.payloadKinds, Boolean(deps.api), seed, remember],
137
183
  );
138
184
 
139
- const clear = useCallback(async () => {
140
- await ref.current.api?.clear();
141
- live.current?.abort();
142
- seed({ id: null, messages: [] });
143
- }, [seed]);
185
+ const refreshSessions = useCallback(() => {
186
+ ref.current.api?.list(new AbortController().signal).then(setSessions, () => undefined);
187
+ }, []);
188
+
189
+ const show = useCallback(
190
+ async (read: (signal: AbortSignal) => Promise<StoredConversation>): Promise<boolean> => {
191
+ live.current?.abort();
192
+ const controller = new AbortController();
193
+ live.current = controller;
194
+ try {
195
+ const conversation = await read(controller.signal);
196
+ if (controller.signal.aborted) return false;
197
+ setNotice(null);
198
+ seed(conversation);
199
+ return true;
200
+ } catch {
201
+ return false;
202
+ }
203
+ },
204
+ [seed],
205
+ );
206
+
207
+ const startNew = useCallback(async () => {
208
+ if ((ref.current.api && current.current === null) || (thread.kind === 'ready' && thread.history.length === 0)) return true; // already a blank page
209
+ const api = ref.current.api;
210
+ if (!api) return show(async () => NO_SESSION);
211
+ return show(async () => {
212
+ const created = await api.create();
213
+ return { id: created.id, messages: [] };
214
+ });
215
+ }, [show, thread]);
216
+
217
+ const switchTo = useCallback(
218
+ async (id: string) => (id === current.current || !ref.current.api ? true : show((signal) => ref.current.api!.load(id, signal))),
219
+ [show],
220
+ );
144
221
 
145
- return { thread, adapter, statusLabel: phase ? ref.current.copy.status[phase] : null, notice, reload: load, clear };
222
+ return { thread, adapter, statusLabel: phase ? ref.current.copy.status[phase] : null, notice, reload: load, sessions, refreshSessions, busy, startNew, switchTo };
146
223
  }
@@ -18,7 +18,16 @@ export interface StoredMessage {
18
18
  created_at: string;
19
19
  }
20
20
 
21
+ /** One session with its last messages; `id: null` is the state before the first send (no session exists yet, or the host is anonymous). */
21
22
  export interface StoredConversation {
22
23
  id: string | null;
23
24
  messages: StoredMessage[];
24
25
  }
26
+
27
+ /** A session in the list (`GET threads/`): `title` is the first user message, empty until its first answer finishes. */
28
+ export interface StoredThread {
29
+ id: string;
30
+ title: string;
31
+ last_message_at: string;
32
+ message_count: number;
33
+ }