@hanzo/rooms 0.1.35 → 0.1.37

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,181 @@
1
+ // Changes asked of the conversation store that every list already shows.
2
+ //
3
+ // A pin, a rename, an archive or a delete shows at once: each list draws the
4
+ // changes held here over what it last read (`view`). No React, so a spec reads
5
+ // it as it is.
6
+ //
7
+ // ONE CONVERSATION'S CHANGES GO TO THE STORE IN ORDER. Each waits for the one
8
+ // before it to be answered, so two toggles are never answered out of order and
9
+ // the last one asked is the one the store keeps. A change is a PATCH of the
10
+ // fields it names, laid over the row; a refusal takes that one change back and
11
+ // leaves the others standing, so a refused rename does not unpin a pin still
12
+ // in flight.
13
+ //
14
+ // A SETTLED CHANGE IS KEPT UNTIL EVERY LIST HAS READ AFTER IT. Dropping it the
15
+ // moment the store answers would draw the list's older read, the row back where
16
+ // it was, until the list read again. So every read and every answer takes a
17
+ // moment from one clock (`tick`): a list read before a change settled still
18
+ // shows the change, one read after shows its own read, and once every list on
19
+ // the page has read after it the change is folded away (`sweep`).
20
+ let clock = 0;
21
+ /** The next moment. A read takes one as it is asked for, an answer as it lands. */
22
+ export const tick = () => ++clock;
23
+ const entries = new Map();
24
+ const readers = new Set();
25
+ const listeners = new Set();
26
+ let version = 0;
27
+ let owner = null;
28
+ const publish = () => {
29
+ version++;
30
+ for (const listener of listeners)
31
+ listener();
32
+ };
33
+ /** For `useSyncExternalStore`: told whenever a change is made, settled, taken back or dropped. */
34
+ export function subscribe(listener) {
35
+ listeners.add(listener);
36
+ return () => {
37
+ listeners.delete(listener);
38
+ };
39
+ }
40
+ /** For `useSyncExternalStore`: a number that moves whenever the changes do. */
41
+ export const snapshot = () => version;
42
+ /**
43
+ * Whose conversations these are: the client the lists read through. Another
44
+ * client is another reader or organization, and nothing asked under the last
45
+ * one is drawn over its rows.
46
+ */
47
+ export function bind(next) {
48
+ if (owner !== null && owner !== next && entries.size) {
49
+ entries.clear();
50
+ publish();
51
+ }
52
+ owner = next;
53
+ }
54
+ /** A list joins the page; the returned function is its leaving. */
55
+ export function enlist(reader) {
56
+ readers.add(reader);
57
+ return () => {
58
+ readers.delete(reader);
59
+ sweep();
60
+ };
61
+ }
62
+ /** Every list on the page reads the store again. */
63
+ export function reread() {
64
+ for (const reader of readers)
65
+ reader.reload();
66
+ }
67
+ /** Folds each settled change every list on the page has read after into the thread it was laid over. */
68
+ export function sweep() {
69
+ let moved = false;
70
+ for (const [id, entry] of entries) {
71
+ while (entry.layers.length) {
72
+ const layer = entry.layers[0];
73
+ if (!layer.settled)
74
+ break;
75
+ let read = true;
76
+ for (const reader of readers)
77
+ if (reader.at <= layer.settled)
78
+ read = false;
79
+ if (!read)
80
+ break;
81
+ entry.layers.shift();
82
+ moved = true;
83
+ if (!layer.patch) {
84
+ entry.layers.length = 0;
85
+ break;
86
+ }
87
+ entry.seed = { ...entry.seed, ...layer.patch };
88
+ }
89
+ if (!entry.layers.length)
90
+ entries.delete(id);
91
+ }
92
+ if (moved)
93
+ publish();
94
+ }
95
+ /** Pinned first, then most recently spoken in: the store's own order. */
96
+ const first = (a, b) => Number(Boolean(b.pinned)) - Number(Boolean(a.pinned)) || (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '');
97
+ /** One conversation added to a list in the store's order. */
98
+ const place = (threads, t) => {
99
+ const i = threads.findIndex((one) => first(t, one) < 0);
100
+ return i < 0 ? [...threads, t] : [...threads.slice(0, i), t, ...threads.slice(i)];
101
+ };
102
+ /** A thread with changes laid over it in order; null once one of them deleted it. */
103
+ const lay = (t, layers) => {
104
+ let out = t;
105
+ for (const layer of layers) {
106
+ if (!layer.patch)
107
+ return null;
108
+ out = { ...out, ...layer.patch };
109
+ }
110
+ return out;
111
+ };
112
+ /**
113
+ * A list as it reads with every change it has not read yet: `threads` read at
114
+ * `at`, from the archived shelf or the active one. A deleted conversation
115
+ * leaves; one archived or brought back leaves the shelf it is no longer on and
116
+ * joins the other; any other change shows in place.
117
+ */
118
+ export function view(threads, at, archived) {
119
+ let out = threads;
120
+ for (const [id, entry] of entries) {
121
+ const unread = entry.layers.filter((layer) => !layer.settled || at < layer.settled);
122
+ if (!unread.length)
123
+ continue;
124
+ const i = out.findIndex((t) => t.id === id);
125
+ // A row this list read carries every change it read after; one it lacks is the thread before them all.
126
+ const want = i >= 0 ? lay(out[i], unread) : lay(entry.seed, entry.layers);
127
+ if (!want || Boolean(want.archived) !== archived) {
128
+ if (i >= 0)
129
+ out = out.filter((t) => t.id !== id);
130
+ }
131
+ else {
132
+ out = i >= 0 ? out.map((t) => (t.id === id ? want : t)) : place(out, want);
133
+ }
134
+ }
135
+ return out;
136
+ }
137
+ /**
138
+ * Asks the store for one change to `t`, shown at once: `patch` the fields it
139
+ * sets, or null for a delete, and `send` the request, made once every earlier
140
+ * change to the same conversation has been answered. What the store answers
141
+ * for those fields is what shows. On a refusal this change alone is taken back
142
+ * and the refusal thrown.
143
+ */
144
+ export function attempt(t, patch, send) {
145
+ let entry = entries.get(t.id);
146
+ if (!entry) {
147
+ entry = { seed: t, layers: [], queue: Promise.resolve() };
148
+ entries.set(t.id, entry);
149
+ }
150
+ const held = entry;
151
+ const mine = { patch: patch ? { ...patch } : null, settled: 0 };
152
+ held.layers.push(mine);
153
+ publish();
154
+ const run = held.queue.then(async () => {
155
+ try {
156
+ const answer = await send();
157
+ if (answer && mine.patch) {
158
+ const said = answer;
159
+ for (const key of Object.keys(mine.patch))
160
+ if (said[key] !== undefined)
161
+ Object.assign(mine.patch, { [key]: said[key] });
162
+ }
163
+ mine.settled = tick();
164
+ publish();
165
+ }
166
+ catch (e) {
167
+ const i = held.layers.indexOf(mine);
168
+ if (i >= 0) {
169
+ held.layers.splice(i, 1);
170
+ if (!held.layers.length && entries.get(t.id) === held)
171
+ entries.delete(t.id);
172
+ publish();
173
+ }
174
+ throw e;
175
+ }
176
+ reread();
177
+ sweep();
178
+ });
179
+ held.queue = run.catch(() => { });
180
+ return run;
181
+ }
package/dist/recent.d.ts CHANGED
@@ -9,10 +9,12 @@ export interface Recent {
9
9
  began: string;
10
10
  status?: string;
11
11
  project?: string;
12
+ /** A conversation its person pinned: listed first, under Pinned. */
13
+ pinned?: boolean;
12
14
  }
13
- export type Status = 'all' | 'running' | 'paused' | 'done' | 'error' | 'stopped';
15
+ export type Status = 'all' | 'running' | 'paused' | 'done' | 'error' | 'stopped' | 'archived';
14
16
  export type Since = 'any' | 'day' | 'week' | 'month';
15
- export type Group = 'date' | 'state' | 'project' | 'starred' | 'none';
17
+ export type Group = 'date' | 'state' | 'project' | 'none';
16
18
  export type Sort = 'activity' | 'created' | 'title';
17
19
  /** How the list is narrowed, grouped and sorted. */
18
20
  export interface View {
@@ -35,11 +37,13 @@ export declare const MENUS: Record<Mode, {
35
37
  }>;
36
38
  /** A kept view as this mode may hold it: anything a menu does not offer reads as the plain view's. */
37
39
  export declare function viewOf(mode: Mode, kept: unknown): View;
38
- /** Chat's conversations as rows. */
40
+ /** Chat's conversations as rows: an archived one's status says so. */
39
41
  export declare function chats(threads: {
40
42
  id: string;
41
43
  title?: string;
42
44
  updatedAt?: string;
45
+ pinned?: boolean;
46
+ archived?: boolean;
43
47
  }[]): Recent[];
44
48
  /** Dev's runs as rows. */
45
49
  export declare function runs(sessions: {
@@ -53,13 +57,16 @@ export declare function runs(sessions: {
53
57
  }[]): Recent[];
54
58
  /**
55
59
  * The list as drawn: the rows the view and the words admit, sorted, and cut into
56
- * headed groups in the order they read. A view grouped by None is one group with
60
+ * headed groups in the order they read. Pinned rows come first, under Pinned,
61
+ * whatever the grouping; a view grouped by None holds the rest in one group with
57
62
  * no heading.
58
63
  */
59
- export declare function arrange(rows: Recent[], view: View, words: string, starred: Set<string>, now?: Date): {
64
+ export declare function arrange(rows: Recent[], view: View, words: string, now?: Date): {
60
65
  title: string;
61
66
  rows: Recent[];
62
67
  }[];
68
+ /** The heading pinned rows read under, first in every view. */
69
+ export declare const PINNED = "Pinned";
63
70
  /**
64
71
  * How long ago a row was last active, as a list reads it at a glance: `now`,
65
72
  * `12m`, `3h`, `2d`, `5w`, `4mo`, `2y`. '' for an instant that is not one.
package/dist/recent.js CHANGED
@@ -17,11 +17,14 @@ export const SINCE = [
17
17
  ];
18
18
  /** What each mode's menu offers, section by section, as Claude's Recents menu does. */
19
19
  export const MENUS = {
20
+ // A chat's status is whether it is archived: the store lists the two apart.
20
21
  chat: {
21
- status: [],
22
+ status: [
23
+ { id: 'all', label: 'Active' },
24
+ { id: 'archived', label: 'Archived' },
25
+ ],
22
26
  group: [
23
27
  { id: 'date', label: 'Date' },
24
- { id: 'starred', label: 'Starred' },
25
28
  { id: 'none', label: 'None' },
26
29
  ],
27
30
  sort: [
@@ -56,9 +59,17 @@ export function viewOf(mode, kept) {
56
59
  sort: pick(k.sort, m.sort, PLAIN.sort),
57
60
  };
58
61
  }
59
- /** Chat's conversations as rows. */
62
+ /** Chat's conversations as rows: an archived one's status says so. */
60
63
  export function chats(threads) {
61
- return threads.map((t) => ({ kind: 'chat', id: t.id, title: t.title || 'Untitled', at: t.updatedAt ?? '', began: t.updatedAt ?? '' }));
64
+ return threads.map((t) => ({
65
+ kind: 'chat',
66
+ id: t.id,
67
+ title: t.title || 'Untitled',
68
+ at: t.updatedAt ?? '',
69
+ began: t.updatedAt ?? '',
70
+ ...(t.archived ? { status: 'archived' } : {}),
71
+ ...(t.pinned ? { pinned: true } : {}),
72
+ }));
62
73
  }
63
74
  /** Dev's runs as rows. */
64
75
  export function runs(sessions) {
@@ -96,10 +107,11 @@ function day(at, now) {
96
107
  const newest = (a, b) => (a < b ? 1 : a > b ? -1 : 0);
97
108
  /**
98
109
  * The list as drawn: the rows the view and the words admit, sorted, and cut into
99
- * headed groups in the order they read. A view grouped by None is one group with
110
+ * headed groups in the order they read. Pinned rows come first, under Pinned,
111
+ * whatever the grouping; a view grouped by None holds the rest in one group with
100
112
  * no heading.
101
113
  */
102
- export function arrange(rows, view, words, starred, now = new Date()) {
114
+ export function arrange(rows, view, words, now = new Date()) {
103
115
  const w = words.trim().toLowerCase();
104
116
  const kept = rows.filter((r) => (!w || r.title.toLowerCase().includes(w)) &&
105
117
  (view.status === 'all' || r.status === view.status) &&
@@ -113,14 +125,14 @@ export function arrange(rows, view, words, starred, now = new Date()) {
113
125
  return STATE[r.status ?? ''] ?? 'Other';
114
126
  case 'project':
115
127
  return r.project || 'No project';
116
- case 'starred':
117
- return starred.has(r.id) ? 'Starred' : 'Others';
118
128
  default:
119
129
  return '';
120
130
  }
121
131
  };
122
132
  const groups = [];
123
133
  for (const r of kept) {
134
+ if (r.pinned)
135
+ continue;
124
136
  const title = headOf(r);
125
137
  const g = groups.find((one) => one.title === title);
126
138
  if (g)
@@ -128,12 +140,13 @@ export function arrange(rows, view, words, starred, now = new Date()) {
128
140
  else
129
141
  groups.push({ title, rows: [r] });
130
142
  }
131
- if (view.group === 'starred')
132
- groups.sort((a, b) => (a.title === 'Starred' ? -1 : b.title === 'Starred' ? 1 : 0));
133
143
  if (view.group === 'date')
134
144
  groups.sort((a, b) => DAYS.indexOf(a.title) - DAYS.indexOf(b.title));
135
- return groups;
145
+ const pinned = kept.filter((r) => r.pinned);
146
+ return pinned.length ? [{ title: PINNED, rows: pinned }, ...groups] : groups;
136
147
  }
148
+ /** The heading pinned rows read under, first in every view. */
149
+ export const PINNED = 'Pinned';
137
150
  /**
138
151
  * How long ago a row was last active, as a list reads it at a glance: `now`,
139
152
  * `12m`, `3h`, `2d`, `5w`, `4mo`, `2y`. '' for an instant that is not one.
package/dist/stars.d.ts CHANGED
@@ -14,3 +14,11 @@ export declare const titleOf: (name: string, direct?: boolean) => string;
14
14
  * between them through the four components that separate them.
15
15
  */
16
16
  export declare function useStarred(kind?: string): [Set<string>, (id: string) => void];
17
+ /**
18
+ * The conversations starred in this browser before a pin was the server's
19
+ * (@hanzo/rooms 0.1.36 stopped reading the set), made pins once. Each is asked
20
+ * for as a pin through `pin`; once the store has taken any of them the stars
21
+ * are forgotten, and a store that takes none — one without the route yet —
22
+ * leaves them for a later visit. Answers whether they were carried.
23
+ */
24
+ export declare function carry(pin: (id: string) => Promise<unknown>): Promise<boolean>;
package/dist/stars.js CHANGED
@@ -73,3 +73,35 @@ export function useStarred(kind = 'rooms') {
73
73
  }, [kind]);
74
74
  return [starred, toggle];
75
75
  }
76
+ /**
77
+ * The conversations starred in this browser before a pin was the server's
78
+ * (@hanzo/rooms 0.1.36 stopped reading the set), made pins once. Each is asked
79
+ * for as a pin through `pin`; once the store has taken any of them the stars
80
+ * are forgotten, and a store that takes none — one without the route yet —
81
+ * leaves them for a later visit. Answers whether they were carried.
82
+ */
83
+ export async function carry(pin) {
84
+ const key = keyOf('threads');
85
+ let ids;
86
+ try {
87
+ ids = JSON.parse(localStorage.getItem(key) || '[]');
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ if (!Array.isArray(ids))
93
+ return false;
94
+ const named = ids.filter((id) => typeof id === 'string' && id !== '');
95
+ if (!named.length)
96
+ return false;
97
+ const said = await Promise.allSettled(named.map((id) => pin(id)));
98
+ if (!said.some((one) => one.status === 'fulfilled'))
99
+ return false;
100
+ try {
101
+ localStorage.removeItem(key);
102
+ }
103
+ catch {
104
+ // A browser that refuses storage asks again next visit; a pin asked twice is still one pin.
105
+ }
106
+ return true;
107
+ }
@@ -0,0 +1,57 @@
1
+ import { type ReactElement, type ReactNode } from 'react';
2
+ import type { Thread } from '@hanzo/ai';
3
+ export interface ThreadList {
4
+ threads: Thread[];
5
+ loading: boolean;
6
+ error: Error | null;
7
+ reload: () => void;
8
+ }
9
+ /**
10
+ * The member's conversations: pinned first, then most recently spoken in, the
11
+ * archived ones left out — or, with `archived`, those alone — with every change
12
+ * in flight drawn over them. It reads again whenever a conversation changes
13
+ * here, and once for `seek` when it does not hold it: a conversation it has not
14
+ * seen is one just begun.
15
+ */
16
+ export declare function useThreadList(archived?: boolean, seek?: string | null): ThreadList;
17
+ /** What a conversation can be asked to do. Each shows at once; a refusal takes it back and says so in a toast. */
18
+ export declare function useThreadActions(): {
19
+ pin: (t: Thread) => Promise<boolean>;
20
+ archive: (t: Thread) => Promise<boolean>;
21
+ rename: (t: Thread, title: string) => Promise<boolean>;
22
+ remove: (t: Thread) => Promise<boolean>;
23
+ };
24
+ /** A conversation's own address, whole: the app at `/?chat=<id>` (lib/host `talk`), wherever the rooms are drawn. */
25
+ export declare const href: (id: string) => string;
26
+ /**
27
+ * A list of conversations with their menu: a right-click on a row or a
28
+ * long-press opens it at the point, a row's ⋯ hangs it from itself. Its one
29
+ * child holds the rows (`ThreadRow`). `threads` are the list's conversations by
30
+ * id, `active` the one the pane holds (what the keys act on with no menu open),
31
+ * and `onGone` runs after that one is deleted — the room moves to a new chat —
32
+ * defaulting to emptying the room.
33
+ */
34
+ export declare function ConversationMenu({ threads, active, onGone, children, }: {
35
+ threads: Map<string, Thread>;
36
+ active?: string | null;
37
+ onGone?: () => void;
38
+ children: ReactElement;
39
+ }): import("react").JSX.Element;
40
+ /**
41
+ * One conversation's row, inside a `ConversationMenu`: its menu opens by a
42
+ * right-click anywhere on it, a long-press, or its ⋯; a middle-click or a
43
+ * ⌘/Ctrl-click opens it in a new tab.
44
+ *
45
+ * The ⋯ stands where `aside` (the row's age) stands, and takes its place while
46
+ * the row is hovered, focused, open in the pane or has its menu open, and
47
+ * always on a touch screen, which has no hover to find it by; the rest of the
48
+ * time the age reads there.
49
+ */
50
+ export declare function ThreadRow({ t, active, aside, listed, children, }: {
51
+ t: Thread;
52
+ active?: boolean;
53
+ aside?: ReactNode;
54
+ /** A row of a list (`role="listitem"`); false where it stands among other rows. */
55
+ listed?: boolean;
56
+ children: ReactElement;
57
+ }): import("react").JSX.Element;