@djangocfg/widget-assistant 0.2.9 → 0.3.2

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.
package/README.md CHANGED
@@ -46,10 +46,26 @@ An unknown `type` is ignored. `wire/fixtures/events.json` holds one of each fram
46
46
 
47
47
  `createTransport` takes `streamUrl` (a path, or a function of the send, for a thread id in the path), `buildBody`, `headers`, `streamFetch`, `getToken` (signed in) or `visitorToken` (anonymous, sent as `x-visitor-token`). Omit `api` from `useSession` for an anonymous host: nothing is fetched and the room starts empty. `context` is a plain record the host defines; the package never reads it. `examples/KbAssistant.tsx` is a knowledge-base host with a `citations` payload, the reference the tests mount.
48
48
 
49
+ ## Sessions (0.3)
50
+
51
+ A session is one thread on the server. `useSession({ transport, api, storage, getContext })` shows one at a time and gives the host `startNew()`, `switchTo(id)`, `sessions` / `refreshSessions()` and `busy` (a turn is streaming: starting or switching waits, Stop ends it).
52
+
53
+ * `api: ThreadApi` is three calls the host maps to its endpoints: `list(signal)` (newest activity first), `create()` (`POST threads/`, always a new row) and `load(id, signal)` (throws `ThreadNotFoundError` on a 404: a stranger's or purged session).
54
+ * On load the remembered session (`storage: { read, write }`, the host picks the key; reads and writes must not throw) is shown, else the newest, else a blank page. **A session is created on the first send, never on load**; the transcript is not reseeded then, so the live message survives.
55
+ * `createTransport({ streamUrl: ({ threadId }) => … })` posts to the session in the path; `SendInput.threadId` is what `threadFor` returned.
56
+ * `<SessionsMenu session={session} />` is the header control: "New chat" and the list of earlier chats (title, "5 minutes ago", a check on the current one). Words come from the bundled dictionaries (`newChat`, `sessions`, `sessionsEmpty`).
57
+ * `<AssistantChat draftRoom={session.thread.session ?? 'new'} />` keeps each session's unsent draft under its own id.
58
+ * There is no delete and no `clear()`: "clear" is "new chat". Retention is the server's job.
59
+ * Without `api` (anonymous host) nothing is listed; `startNew()` just empties the room.
60
+
49
61
  ## Tests
50
62
 
51
63
  `pnpm --filter @djangocfg/widget-assistant check test`: the wire, stream and boundary tests, and `tsc --noEmit`.
52
64
 
53
65
  ## Migrating from 0.1 (a host that spoke the film frames)
54
66
 
55
- 0.2 breaks the wire. In the host's server: `run` carries `thread_id` and `message_id` (not `run_id`, `user_message_id`, `assistant_message_id`); `status.phase` is `thinking|searching|answering` (`reading` and `choosing` are gone); `done` carries no message id; a card payload is `ui_payload {kind: 'film_cards', data}` as before, but the package no longer validates it, so register it: `dataRenderers={{ film_cards: … }}` and `payloadKinds={['film_cards']}`. A stored message has `parts: [{kind, data}]` in place of `widgets: [{kind, ids, caption}]`. `ChatContext` is a plain record (the old `title` and `browse` kinds are the host's to define). `createTransport` still opens `/apix/chat/v1/stream` when no `streamUrl` is given; that default goes in 0.3. The dictionaries lose `statusReading` and `statusChoosing` and gain `statusAnswering`; the bundled `lockedBody` and `hint` no longer talk about films.
67
+ 0.2 breaks the wire. In the host's server: `run` carries `thread_id` and `message_id` (not `run_id`, `user_message_id`, `assistant_message_id`); `status.phase` is `thinking|searching|answering` (`reading` and `choosing` are gone); `done` carries no message id; a card payload is `ui_payload {kind: 'film_cards', data}` as before, but the package no longer validates it, so register it: `dataRenderers={{ film_cards: … }}` and `payloadKinds={['film_cards']}`. A stored message has `parts: [{kind, data}]` in place of `widgets: [{kind, ids, caption}]`. `ChatContext` is a plain record (the old `title` and `browse` kinds are the host's to define). `createTransport` still opens `/apix/chat/v1/stream` when no `streamUrl` is given; that default stays for anonymous hosts only; a signed-in host with sessions sets `streamUrl`. The dictionaries lose `statusReading` and `statusChoosing` and gain `statusAnswering`; the bundled `lockedBody` and `hint` no longer talk about films.
68
+
69
+ ## Migrating from 0.2 (one thread, `clear()`)
70
+
71
+ 0.3 replaces the single-thread `ThreadApi { load, clear }` with `ThreadApi { list, create, load(id, signal) }` (`useSession` takes an optional `storage`), removes `Session.clear` (use `startNew`), swaps the dictionary keys `clear`, `clearConfirm`, `clearYes` for `newChat`, `sessions`, `sessionsEmpty`, and adds `ThreadNotFoundError`, the `not_found` reject code, `SendInput.threadId`, `AdapterDeps.threadFor` / `onActive`, `AssistantChat.draftRoom`, `SessionsMenu`, `useAssistantLocale` and `relativeTime`. A host that kept a hand-written sessions bar can delete it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/widget-assistant",
3
- "version": "0.2.9",
3
+ "version": "0.3.2",
4
4
  "description": "A site assistant on top of @djangocfg/widget-chat: the SSE wire and session layer, a persistent host, docked column, floating button and sheet, dictation, Help and 17 built-in locales",
5
5
  "keywords": [
6
6
  "chat",
@@ -48,15 +48,15 @@
48
48
  "zustand": "^5.0.16"
49
49
  },
50
50
  "peerDependencies": {
51
- "@djangocfg/i18n": "^2.1.619",
52
- "@djangocfg/ui-core": "^2.1.619",
51
+ "@djangocfg/i18n": "^2.1.621",
52
+ "@djangocfg/ui-core": "^2.1.621",
53
53
  "lucide-react": "^1.68.0",
54
54
  "react": "^19.0.0"
55
55
  },
56
56
  "devDependencies": {
57
- "@djangocfg/i18n": "^2.1.619",
58
- "@djangocfg/typescript-config": "^2.1.619",
59
- "@djangocfg/ui-core": "^2.1.619",
57
+ "@djangocfg/i18n": "^2.1.621",
58
+ "@djangocfg/typescript-config": "^2.1.621",
59
+ "@djangocfg/ui-core": "^2.1.621",
60
60
  "@storybook/react-vite": "^10.6.5",
61
61
  "@types/node": "^25.9.5",
62
62
  "@types/react": "19.3.0",
@@ -33,8 +33,10 @@ interface Props {
33
33
  adapter: ChatAdapter;
34
34
  /** Example prompts shown in an empty chat. */
35
35
  suggestions: string[];
36
- /** The room on screen: `id` changes when the thread is reloaded (reconcile, clear), which reseeds the transcript. */
36
+ /** The room on screen: `id` changes when the thread is reloaded (reconcile, a new or another session), which reseeds the transcript. */
37
37
  conversation?: { id?: string; history: readonly ChatMessage[] };
38
+ /** What the unsent draft is kept under: the session's own id, so each session keeps its draft across reseeds. Absent: the conversation `id`. */
39
+ draftRoom?: string;
38
40
  /** The host's drawing of structured parts, by `kind` (for example `citations`). Unknown kinds fall through to the widget. */
39
41
  dataRenderers?: Readonly<Record<string, DataPartRenderer>>;
40
42
  /** Dictation language and its picker (the microphone menu). Absent: the browser's default engine with the widget's default language. */
@@ -57,7 +59,7 @@ const NO_RENDERERS: Readonly<Record<string, DataPartRenderer>> = {};
57
59
  * recall of sent prompts with the arrow keys, an unsent draft that survives a reload). The widget's packaged layout shows only a bare composer, so the layout
58
60
  * is composed here from its own primitives: every behaviour is the widget's, nothing is re-implemented.
59
61
  */
60
- export function AssistantChat({ adapter, suggestions, conversation = EMPTY_ROOM, dataRenderers = NO_RENDERERS, speech, notice, onRetry, focusWhen, refocus, className }: Props) {
62
+ export function AssistantChat({ adapter, suggestions, conversation = EMPTY_ROOM, draftRoom, dataRenderers = NO_RENDERERS, speech, notice, onRetry, focusWhen, refocus, className }: Props) {
61
63
  const renderers = useMemo(
62
64
  () => ({
63
65
  text: ({ part }: PartRenderProps) => (part.type === 'text' ? <Response>{part.text}</Response> : null),
@@ -106,7 +108,7 @@ export function AssistantChat({ adapter, suggestions, conversation = EMPTY_ROOM,
106
108
  children: (
107
109
  <>
108
110
  <ChatTranscript footer={(message) => <MessageFooter message={message} />} />
109
- <AssistantComposer speech={speech} draftRoom={conversation.id ?? 'assistant'} focusWhen={focusWhen} refocus={refocus} />
111
+ <AssistantComposer speech={speech} draftRoom={draftRoom ?? conversation.id ?? 'assistant'} focusWhen={focusWhen} refocus={refocus} />
110
112
  </>
111
113
  ),
112
114
  }}
package/src/i18n/index.ts CHANGED
@@ -34,3 +34,6 @@ const kit = createCopy(ASSISTANT_DICTIONARIES);
34
34
  * `@djangocfg/i18n` provider, else English. The host passes no strings.
35
35
  */
36
36
  export const useAssistantCopy = kit.useCopy;
37
+
38
+ /** The bundled locale the assistant resolved to (feed it to `Intl`). */
39
+ export const useAssistantLocale = kit.useLocale;
@@ -20,9 +20,9 @@ export const ar: AssistantCopy = {
20
20
  reconnecting: 'إعادة الاتصال…',
21
21
  stalled: 'ما زالت الإجابة قيد الإعداد.',
22
22
  retry: 'إعادة المحاولة',
23
- clear: 'مسح المحادثة',
24
- clearConfirm: 'حذف هذه المحادثة؟ لا يمكن التراجع.',
25
- clearYes: 'حذف',
23
+ newChat: 'محادثة جديدة',
24
+ sessions: 'المحادثات السابقة',
25
+ sessionsEmpty: 'لا توجد محادثات سابقة بعد',
26
26
  loadError: 'تعذّر تحميل المحادثة.',
27
27
  helpLabel: 'مساعدة',
28
28
  helpDictate: 'بدء الإملاء أو إيقافه',
@@ -20,9 +20,9 @@ export const da: AssistantCopy = {
20
20
  reconnecting: 'Forbinder igen…',
21
21
  stalled: 'Svaret er stadig undervejs.',
22
22
  retry: 'Prøv igen',
23
- clear: 'Ryd chatten',
24
- clearConfirm: 'Slet denne samtale? Det kan ikke fortrydes.',
25
- clearYes: 'Slet',
23
+ newChat: 'Ny chat',
24
+ sessions: 'Tidligere chats',
25
+ sessionsEmpty: 'Ingen tidligere chats endnu',
26
26
  loadError: 'Samtalen kunne ikke indlæses.',
27
27
  helpLabel: 'Hjælp',
28
28
  helpDictate: 'Start eller stop diktering',
@@ -20,9 +20,9 @@ export const de: AssistantCopy = {
20
20
  reconnecting: 'Verbinde neu…',
21
21
  stalled: 'Die Antwort wird noch erstellt.',
22
22
  retry: 'Erneut versuchen',
23
- clear: 'Chat leeren',
24
- clearConfirm: 'Diese Unterhaltung löschen? Das lässt sich nicht rückgängig machen.',
25
- clearYes: 'Löschen',
23
+ newChat: 'Neuer Chat',
24
+ sessions: 'Frühere Chats',
25
+ sessionsEmpty: 'Noch keine früheren Chats',
26
26
  loadError: 'Die Unterhaltung konnte nicht geladen werden.',
27
27
  helpLabel: 'Hilfe',
28
28
  helpDictate: 'Diktat starten oder beenden',
@@ -20,9 +20,9 @@ export const en: AssistantCopy = {
20
20
  reconnecting: 'Reconnecting…',
21
21
  stalled: 'Still working on the answer.',
22
22
  retry: 'Retry',
23
- clear: 'Clear chat',
24
- clearConfirm: 'Delete this conversation? This cannot be undone.',
25
- clearYes: 'Delete',
23
+ newChat: 'New chat',
24
+ sessions: 'Earlier chats',
25
+ sessionsEmpty: 'No earlier chats yet',
26
26
  loadError: 'Could not load the conversation.',
27
27
  helpLabel: 'Help',
28
28
  helpDictate: 'Start or stop dictation',
@@ -20,9 +20,9 @@ export const es: AssistantCopy = {
20
20
  reconnecting: 'Reconectando…',
21
21
  stalled: 'La respuesta sigue en curso.',
22
22
  retry: 'Reintentar',
23
- clear: 'Borrar chat',
24
- clearConfirm: '¿Eliminar esta conversación? No se puede deshacer.',
25
- clearYes: 'Eliminar',
23
+ newChat: 'Nuevo chat',
24
+ sessions: 'Chats anteriores',
25
+ sessionsEmpty: 'Aún no hay chats anteriores',
26
26
  loadError: 'No se pudo cargar la conversación.',
27
27
  helpLabel: 'Ayuda',
28
28
  helpDictate: 'Iniciar o detener el dictado',
@@ -20,9 +20,9 @@ export const fr: AssistantCopy = {
20
20
  reconnecting: 'Reconnexion…',
21
21
  stalled: 'La réponse est encore en cours.',
22
22
  retry: 'Réessayer',
23
- clear: 'Effacer le chat',
24
- clearConfirm: 'Supprimer cette conversation ? Cette action est irréversible.',
25
- clearYes: 'Supprimer',
23
+ newChat: 'Nouvelle discussion',
24
+ sessions: 'Discussions précédentes',
25
+ sessionsEmpty: 'Aucune discussion précédente',
26
26
  loadError: 'Impossible de charger la conversation.',
27
27
  helpLabel: 'Aide',
28
28
  helpDictate: 'Démarrer ou arrêter la dictée',
@@ -20,9 +20,9 @@ export const it: AssistantCopy = {
20
20
  reconnecting: 'Riconnessione…',
21
21
  stalled: 'La risposta è ancora in corso.',
22
22
  retry: 'Riprova',
23
- clear: 'Cancella chat',
24
- clearConfirm: 'Eliminare questa conversazione? Non si può annullare.',
25
- clearYes: 'Elimina',
23
+ newChat: 'Nuova chat',
24
+ sessions: 'Chat precedenti',
25
+ sessionsEmpty: 'Ancora nessuna chat precedente',
26
26
  loadError: 'Impossibile caricare la conversazione.',
27
27
  helpLabel: 'Aiuto',
28
28
  helpDictate: 'Avvia o ferma la dettatura',
@@ -20,9 +20,9 @@ export const ja: AssistantCopy = {
20
20
  reconnecting: '再接続中…',
21
21
  stalled: 'まだ回答を作成中です。',
22
22
  retry: '再試行',
23
- clear: 'チャットを消去',
24
- clearConfirm: 'この会話を削除しますか?元に戻せません。',
25
- clearYes: '削除',
23
+ newChat: '新しいチャット',
24
+ sessions: '過去のチャット',
25
+ sessionsEmpty: '過去のチャットはまだありません',
26
26
  loadError: '会話を読み込めませんでした。',
27
27
  helpLabel: 'ヘルプ',
28
28
  helpDictate: '音声入力の開始/停止',
@@ -20,9 +20,9 @@ export const ko: AssistantCopy = {
20
20
  reconnecting: '다시 연결하는 중…',
21
21
  stalled: '아직 답변을 준비 중입니다.',
22
22
  retry: '다시 시도',
23
- clear: '대화 지우기',
24
- clearConfirm: '이 대화를 삭제할까요? 되돌릴 수 없습니다.',
25
- clearYes: '삭제',
23
+ newChat: '새 채팅',
24
+ sessions: '이전 채팅',
25
+ sessionsEmpty: '이전 채팅이 아직 없습니다',
26
26
  loadError: '대화를 불러오지 못했습니다.',
27
27
  helpLabel: '도움말',
28
28
  helpDictate: '음성 입력 시작/중지',
@@ -20,9 +20,9 @@ export const nl: AssistantCopy = {
20
20
  reconnecting: 'Opnieuw verbinden…',
21
21
  stalled: 'Het antwoord wordt nog gemaakt.',
22
22
  retry: 'Opnieuw proberen',
23
- clear: 'Chat wissen',
24
- clearConfirm: 'Dit gesprek verwijderen? Dit kan niet ongedaan worden gemaakt.',
25
- clearYes: 'Verwijderen',
23
+ newChat: 'Nieuwe chat',
24
+ sessions: 'Eerdere chats',
25
+ sessionsEmpty: 'Nog geen eerdere chats',
26
26
  loadError: 'Het gesprek kon niet worden geladen.',
27
27
  helpLabel: 'Help',
28
28
  helpDictate: 'Dicteren starten of stoppen',
@@ -20,9 +20,9 @@ export const no: AssistantCopy = {
20
20
  reconnecting: 'Kobler til igjen…',
21
21
  stalled: 'Svaret lages fortsatt.',
22
22
  retry: 'Prøv igjen',
23
- clear: 'Tøm chatten',
24
- clearConfirm: 'Slette denne samtalen? Det kan ikke angres.',
25
- clearYes: 'Slett',
23
+ newChat: 'Ny chat',
24
+ sessions: 'Tidligere chatter',
25
+ sessionsEmpty: 'Ingen tidligere chatter ennå',
26
26
  loadError: 'Samtalen kunne ikke lastes.',
27
27
  helpLabel: 'Hjelp',
28
28
  helpDictate: 'Start eller stopp diktering',
@@ -20,9 +20,9 @@ export const pl: AssistantCopy = {
20
20
  reconnecting: 'Łączę ponownie…',
21
21
  stalled: 'Odpowiedź wciąż powstaje.',
22
22
  retry: 'Spróbuj ponownie',
23
- clear: 'Wyczyść czat',
24
- clearConfirm: 'Usunąć tę rozmowę? Nie można tego cofnąć.',
25
- clearYes: 'Usuń',
23
+ newChat: 'Nowy czat',
24
+ sessions: 'Wcześniejsze czaty',
25
+ sessionsEmpty: 'Brak wcześniejszych czatów',
26
26
  loadError: 'Nie udało się wczytać rozmowy.',
27
27
  helpLabel: 'Pomoc',
28
28
  helpDictate: 'Włącz lub zatrzymaj dyktowanie',
@@ -20,9 +20,9 @@ export const ptBR: AssistantCopy = {
20
20
  reconnecting: 'Reconectando…',
21
21
  stalled: 'A resposta ainda está sendo preparada.',
22
22
  retry: 'Tentar de novo',
23
- clear: 'Limpar conversa',
24
- clearConfirm: 'Excluir esta conversa? Não dá para desfazer.',
25
- clearYes: 'Excluir',
23
+ newChat: 'Novo chat',
24
+ sessions: 'Chats anteriores',
25
+ sessionsEmpty: 'Ainda não há chats anteriores',
26
26
  loadError: 'Não foi possível carregar a conversa.',
27
27
  helpLabel: 'Ajuda',
28
28
  helpDictate: 'Iniciar ou parar o ditado',
@@ -20,9 +20,9 @@ export const ru: AssistantCopy = {
20
20
  reconnecting: 'Подключаемся снова…',
21
21
  stalled: 'Ответ ещё готовится.',
22
22
  retry: 'Повторить',
23
- clear: 'Очистить чат',
24
- clearConfirm: 'Удалить этот разговор? Это нельзя отменить.',
25
- clearYes: 'Удалить',
23
+ newChat: 'Новый чат',
24
+ sessions: 'Прошлые чаты',
25
+ sessionsEmpty: 'Прошлых чатов пока нет',
26
26
  loadError: 'Не удалось загрузить разговор.',
27
27
  helpLabel: 'Справка',
28
28
  helpDictate: 'Начать или остановить диктовку',
@@ -20,9 +20,9 @@ export const sv: AssistantCopy = {
20
20
  reconnecting: 'Återansluter…',
21
21
  stalled: 'Svaret är fortfarande på väg.',
22
22
  retry: 'Försök igen',
23
- clear: 'Rensa chatten',
24
- clearConfirm: 'Ta bort den här konversationen? Det går inte att ångra.',
25
- clearYes: 'Ta bort',
23
+ newChat: 'Ny chatt',
24
+ sessions: 'Tidigare chattar',
25
+ sessionsEmpty: 'Inga tidigare chattar ännu',
26
26
  loadError: 'Konversationen kunde inte laddas.',
27
27
  helpLabel: 'Hjälp',
28
28
  helpDictate: 'Starta eller stoppa diktering',
@@ -20,9 +20,9 @@ export const tr: AssistantCopy = {
20
20
  reconnecting: 'Yeniden bağlanıyor…',
21
21
  stalled: 'Yanıt hâlâ hazırlanıyor.',
22
22
  retry: 'Tekrar dene',
23
- clear: 'Sohbeti temizle',
24
- clearConfirm: 'Bu konuşma silinsin mi? Geri alınamaz.',
25
- clearYes: 'Sil',
23
+ newChat: 'Yeni sohbet',
24
+ sessions: 'Önceki sohbetler',
25
+ sessionsEmpty: 'Henüz önceki sohbet yok',
26
26
  loadError: 'Konuşma yüklenemedi.',
27
27
  helpLabel: 'Yardım',
28
28
  helpDictate: 'Dikteyi başlat veya durdur',
@@ -20,9 +20,9 @@ export const zh: AssistantCopy = {
20
20
  reconnecting: '正在重新连接…',
21
21
  stalled: '仍在生成回答。',
22
22
  retry: '重试',
23
- clear: '清空对话',
24
- clearConfirm: '删除此对话?此操作无法撤销。',
25
- clearYes: '删除',
23
+ newChat: '新对话',
24
+ sessions: '以前的对话',
25
+ sessionsEmpty: '还没有以前的对话',
26
26
  loadError: '无法加载对话。',
27
27
  helpLabel: '帮助',
28
28
  helpDictate: '开始或停止听写',
package/src/i18n/types.ts CHANGED
@@ -26,10 +26,10 @@ export interface AssistantCopy {
26
26
  reconnecting: string;
27
27
  stalled: string;
28
28
  retry: string;
29
- /** Deleting the conversation. */
30
- clear: string;
31
- clearConfirm: string;
32
- clearYes: string;
29
+ /** Sessions: start a new chat, the list of earlier ones, and its empty line. */
30
+ newChat: string;
31
+ sessions: string;
32
+ sessionsEmpty: string;
33
33
  loadError: string;
34
34
  /** The "Help" link under the composer and its list of keys. */
35
35
  helpLabel: string;
package/src/index.ts CHANGED
@@ -1,19 +1,21 @@
1
1
  export { AssistantChat, type DataPartRenderer } from './AssistantChat';
2
2
  export { scriptedTransport, type ScriptStep } from './stream/scriptedTransport';
3
- export { useAssistantCopy, ASSISTANT_DICTIONARIES, type AssistantCopy } from './i18n';
3
+ export { useAssistantCopy, useAssistantLocale, ASSISTANT_DICTIONARIES, type AssistantCopy } from './i18n';
4
4
  export { ChatDock } from './shell/ChatDock';
5
5
  export { AssistantFrame, type FrameLabels } from './shell/AssistantFrame';
6
6
  export { AssistantFab, type FabHint } from './shell/AssistantFab';
7
7
  export { AssistantLocked } from './shell/AssistantLocked';
8
8
  export { createAssistantUi, useMediaQuery, type UiOptions, type UiState, type UiStorage } from './shell/panelState';
9
9
  export { AssistantHostProvider, AssistantPortal, AssistantSlot } from './Host';
10
- export { useSession, type Session, type SessionCopy, type SessionDeps, type SessionNotice, type ThreadApi, type ThreadState } from './stream/useSession';
10
+ export { useSession, type Session, type SessionCopy, type SessionDeps, type SessionNotice, type ThreadState } from './stream/useSession';
11
+ export { relativeTime, restoreSession, type SessionStorage, type ThreadApi } from './stream/sessions';
12
+ export { SessionsMenu } from './sessions/SessionsMenu';
11
13
  export { createStreamAdapter, type AdapterDeps } from './stream/adapter';
12
14
  export { reconcile, type Reconciled } from './stream/reconcile';
13
15
  export { fromStored, fromStoredThread } from './stream/fromStored';
14
- export { createTransport, ChatHttpError, ChatNetworkError, type ChatTransport, type RejectCode, type SendInput, type TransportConfig } from './wire/transport';
16
+ export { createTransport, ChatHttpError, ChatNetworkError, ThreadNotFoundError, type ChatTransport, type RejectCode, type SendInput, type TransportConfig } from './wire/transport';
15
17
  export { StreamLostError } from './wire/parseSSE';
16
18
  export { decodeFrame, WIRE_VERSION } from './wire/frames';
17
19
  export type { ChatContext, Frame, Phase, StreamErrorCode } from './wire/frames';
18
- export type { StoredConversation, StoredMessage, StoredPart, StoredStatus } from './wire/stored';
20
+ export type { StoredConversation, StoredMessage, StoredPart, StoredStatus, StoredThread } from './wire/stored';
19
21
  export { citationsRenderer, KB_RENDERERS, KB_PAYLOAD_KINDS, type Citation } from './examples/KbAssistant';
@@ -0,0 +1,59 @@
1
+ 'use client';
2
+
3
+ import { Check, History, Plus } from 'lucide-react';
4
+
5
+ import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from '@djangocfg/ui-core/components';
6
+
7
+ import { useAssistantCopy, useAssistantLocale } from '../i18n';
8
+ import type { Session } from '../stream/useSession';
9
+ import { relativeTime } from '../stream/sessions';
10
+
11
+ interface Props {
12
+ session: Pick<Session, 'thread' | 'sessions' | 'busy' | 'startNew' | 'switchTo' | 'refreshSessions'>;
13
+ /** The language tag for "5 minutes ago". Absent: the assistant's active language. */
14
+ locale?: string;
15
+ /** Hide the list and keep only "New chat" (a host with no list endpoint, such as an anonymous one). */
16
+ newOnly?: boolean;
17
+ }
18
+
19
+ /**
20
+ * The chat's header controls: "New chat" (a new session; the old ones stay) and the list of earlier sessions, newest first, to switch back.
21
+ * Both wait while an answer streams: Stop ends it first. Words come from the assistant's own dictionaries.
22
+ */
23
+ export function SessionsMenu({ session, locale, newOnly = false }: Props) {
24
+ const copy = useAssistantCopy(locale);
25
+ const active = useAssistantLocale(locale);
26
+ const current = session.thread.kind === 'ready' ? session.thread.session : null;
27
+ return (
28
+ <div className="flex items-center" data-testid="sessions-menu">
29
+ <Button variant="ghost" size="icon" aria-label={copy.newChat} title={copy.newChat} disabled={session.busy} onClick={() => void session.startNew()}>
30
+ <Plus className="size-5" aria-hidden="true" />
31
+ </Button>
32
+ {newOnly ? null : (
33
+ <DropdownMenu onOpenChange={(open) => open && session.refreshSessions()}>
34
+ <DropdownMenuTrigger asChild>
35
+ <Button variant="ghost" size="icon" aria-label={copy.sessions} title={copy.sessions}>
36
+ <History className="size-5" aria-hidden="true" />
37
+ </Button>
38
+ </DropdownMenuTrigger>
39
+ <DropdownMenuContent align="end" sideOffset={4} className="max-h-96 w-72 overflow-y-auto">
40
+ <DropdownMenuLabel>{copy.sessions}</DropdownMenuLabel>
41
+ {session.sessions === null ? null : session.sessions.length === 0 ? (
42
+ <p className="px-2 py-1.5 text-xs text-muted-foreground">{copy.sessionsEmpty}</p>
43
+ ) : (
44
+ session.sessions.map((item) => (
45
+ <DropdownMenuItem key={item.id} disabled={session.busy} onSelect={() => void session.switchTo(item.id)} className="items-start gap-2">
46
+ <Check className={`mt-0.5 size-4 shrink-0 ${item.id === current ? '' : 'invisible'}`} aria-hidden="true" />
47
+ <span className="min-w-0 flex-1">
48
+ <span className="block truncate">{item.title || copy.newChat}</span>
49
+ <span className="block text-xs text-muted-foreground">{relativeTime(item.last_message_at, active)}</span>
50
+ </span>
51
+ </DropdownMenuItem>
52
+ ))
53
+ )}
54
+ </DropdownMenuContent>
55
+ </DropdownMenu>
56
+ )}
57
+ </div>
58
+ );
59
+ }
@@ -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
+ }
@@ -2,7 +2,7 @@ import { decodeFrame, IDLE_MS, type ChatContext, type Frame } from './frames';
2
2
  import { parseSSE, StreamLostError } from './parseSSE';
3
3
 
4
4
  /** Problem codes the API answers before a stream starts (CONTRACT.md "Auth and errors"). */
5
- export type RejectCode = 'unauthorized' | 'busy' | 'invalid' | 'rate_limited' | 'unavailable' | 'failed';
5
+ export type RejectCode = 'unauthorized' | 'busy' | 'invalid' | 'rate_limited' | 'unavailable' | 'not_found' | 'failed';
6
6
 
7
7
  /** The request was refused before any frame: nothing ran, so a retry with the same `client_id` is safe. */
8
8
  export class ChatHttpError extends Error {
@@ -19,6 +19,13 @@ export class ChatHttpError extends Error {
19
19
  }
20
20
  }
21
21
 
22
+ /** The session is not there (never was, or it is someone else's: the API answers both with a 404). The host forgets the remembered id. */
23
+ export class ThreadNotFoundError extends Error {
24
+ constructor() {
25
+ super('chat thread not found');
26
+ }
27
+ }
28
+
22
29
  /** No answer at all (offline, DNS, reset before the response): nothing is known to have run. */
23
30
  export class ChatNetworkError extends Error {
24
31
  constructor() {
@@ -27,6 +34,8 @@ export class ChatNetworkError extends Error {
27
34
  }
28
35
 
29
36
  export interface SendInput {
37
+ /** The session this message goes to (set by the session layer; read by `streamUrl`). Absent for an anonymous host. */
38
+ threadId?: string;
30
39
  text: string;
31
40
  clientId: string;
32
41
  context?: ChatContext;
@@ -41,7 +50,7 @@ export interface ChatTransport {
41
50
  export interface TransportConfig {
42
51
  /** API origin, no trailing slash; prefixes a relative `streamUrl`. The stream is called directly, never through a Next rewrite. */
43
52
  baseUrl: string;
44
- /** Where one run is opened: a path or URL, or a function read per request (a thread id in the path). Absent: the legacy `/apix/chat/v1/stream` (deprecated, removed in 0.3). */
53
+ /** Where one run is opened: a path or URL, or a function read per request (a thread id in the path). Absent: the legacy `/apix/chat/v1/stream` (anonymous hosts only; a signed-in host with sessions always sets this). */
45
54
  streamUrl?: string | ((input: SendInput) => string);
46
55
  /** The request body. Absent: `{text, client_id, context?}`. */
47
56
  buildBody?: (input: SendInput) => unknown;
@@ -58,7 +67,7 @@ export interface TransportConfig {
58
67
  fetch?: typeof fetch;
59
68
  }
60
69
 
61
- const CODES: readonly string[] = ['unauthorized', 'busy', 'invalid', 'rate_limited', 'unavailable'];
70
+ const CODES: readonly string[] = ['unauthorized', 'busy', 'invalid', 'rate_limited', 'unavailable', 'not_found'];
62
71
  /** A server may name a bad request its own way; these read as `invalid`. */
63
72
  const INVALID: readonly string[] = ['validation_error'];
64
73
 
@@ -67,6 +76,7 @@ function rejectCode(status: number, body: unknown): RejectCode {
67
76
  if (typeof code === 'string' && CODES.includes(code)) return code as RejectCode;
68
77
  if (typeof code === 'string' && INVALID.includes(code)) return 'invalid';
69
78
  if (status === 401) return 'unauthorized';
79
+ if (status === 404) return 'not_found';
70
80
  if (status === 409) return 'busy';
71
81
  if (status === 400 || status === 422) return 'invalid';
72
82
  if (status === 429) return 'rate_limited';