@anthusai/papyrus 5.58.0 → 5.60.0

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.
Files changed (53) hide show
  1. package/amplify/data/message-actions.js +72 -0
  2. package/amplify/data/message-actions.ts +74 -0
  3. package/amplify/data/resolvers/delete-own-message.js +30 -0
  4. package/amplify/data/resolvers/edit-message.js +33 -0
  5. package/amplify/data/resolvers/moderate-message.js +32 -0
  6. package/amplify/data/resolvers/post-message.js +68 -0
  7. package/amplify/data/resolvers/rename-thread.js +28 -0
  8. package/amplify/data/resource.js +8 -5
  9. package/amplify/data/resource.ts +8 -5
  10. package/amplify/data/schema.js +14 -7
  11. package/amplify/data/schema.ts +14 -7
  12. package/components/conversation-context.tsx +29 -2
  13. package/components/conversation-provider.tsx +5 -1
  14. package/components/conversation-thread.tsx +133 -19
  15. package/components/conversations-memory-source.ts +43 -6
  16. package/components/conversations-network-source.ts +13 -1
  17. package/components/conversations-source.ts +15 -3
  18. package/components/conversations-view.tsx +437 -76
  19. package/components/list-detail-layout.tsx +44 -0
  20. package/components/needs-me-bodies.tsx +14 -14
  21. package/components/needs-me-feed.tsx +8 -18
  22. package/components/news-desk-taxonomy-client.ts +142 -57
  23. package/components/newsroom-article-editor.tsx +10 -4
  24. package/components/newsroom-organizer.css +10 -1
  25. package/components/newsroom-references-view.tsx +41 -100
  26. package/components/notification-cards.tsx +4 -4
  27. package/components/notification-center.tsx +5 -7
  28. package/components/proposals-view.tsx +4 -4
  29. package/components/reference-conversation.tsx +5 -0
  30. package/components/reference-feed.tsx +69 -85
  31. package/components/reference-review-card.tsx +1 -1
  32. package/components/references-page-loading.tsx +12 -6
  33. package/components/topic-steering-workspace.tsx +8 -24
  34. package/components/use-conversation.ts +36 -2
  35. package/components/use-feed-preferences.ts +33 -0
  36. package/lib/category-repository.ts +7 -38
  37. package/lib/console-chat-client.ts +8 -178
  38. package/lib/conversation.ts +73 -2
  39. package/lib/needs-me-items.ts +4 -4
  40. package/lib/needs-me-ranking.ts +9 -22
  41. package/lib/newsroom-board.ts +10 -1
  42. package/lib/newsroom-demo-dashboard.ts +0 -7
  43. package/lib/newsroom-organizer-nav.ts +2 -2
  44. package/lib/post-message.ts +54 -0
  45. package/lib/proposals-admin.ts +2 -2
  46. package/lib/reference-feed-ranking.ts +77 -160
  47. package/lib/reference-review-response.ts +19 -0
  48. package/lib/review-drafts.ts +2 -2
  49. package/lib/review-fixture.ts +1 -1
  50. package/lib/review-requests.ts +4 -4
  51. package/lib/semantic-graph.ts +1 -1
  52. package/package.json +5 -1
  53. package/routes.manifest.json +1 -1
@@ -1,19 +1,24 @@
1
1
  "use client";
2
2
 
3
- import { BookmarkIcon, ExternalLinkIcon, InboxIcon, Loader2Icon, MessageCircleIcon, MessageSquareTextIcon, MessagesSquareIcon, NewspaperIcon, TriangleAlertIcon } from "lucide-react";
3
+ import { BookmarkIcon, CheckIcon, ChevronRightIcon, ExternalLinkIcon, InboxIcon, LayoutGridIcon, ListIcon, Loader2Icon, MessageCircleIcon, MessageSquareIcon, MessageSquareTextIcon, MessagesSquareIcon, NewspaperIcon, PencilIcon, SquarePenIcon, TriangleAlertIcon, XIcon } from "lucide-react";
4
4
  import { useCallback, useEffect, useRef, useState } from "react";
5
5
  import type { MessageThreadRecord } from "../lib/category-repository";
6
- import { conversationAge, messageCountLabel, type ConversationMessage } from "../lib/conversation";
6
+ import { NO_VIEWER, canRenameThread, conversationAge, conversationAuthorName, conversationPreviewLine, threadLastAuthorLabel, threadMetadataWithLastAuthor, type ConversationMessage, type ConversationViewer } from "../lib/conversation";
7
+ import { referenceDisplayTitle } from "../lib/newsroom-references";
8
+ import { referenceSourceHost } from "../lib/reference-preview";
7
9
  import { CONVERSATION_SECTIONS, SECTION_EMPTY_COPY, createMergedThreadStream, sectionOfThreadKind, threadKindsForSection, type ConversationSection } from "../lib/conversation-feed";
8
10
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
9
11
  import { cn } from "../lib/utils";
10
12
  import { Composer, ConversationThread } from "./conversation-thread";
13
+ import { ListDetailLayout } from "./list-detail-layout";
11
14
  import type { ConversationReferenceView, ConversationStoryView, ConversationsSource } from "./conversations-source";
12
- import { FEED_GRID, FEED_SURFACE_PROPS, PILL, ReferenceCardHeader } from "./reference-feed";
15
+ import { FEED_GRID, FEED_SURFACE_PROPS, PILL, ReferenceCardHeader, SELECTED_RING } from "./reference-feed";
13
16
 
14
17
  const PAGE_SIZE = 12;
15
- /** A card shows this many of the newest messages; the rest are one button away. */
16
- const LATEST_MESSAGES = 3;
18
+ const LAYOUT_STORAGE_KEY = "papyrus:messages-layout";
19
+ const THREAD_PARAM = "thread";
20
+ /** `?thread=new`: the detail view holds the New thread form. */
21
+ const NEW_THREAD = "new";
17
22
  const STALE_AFTER_MS = 60_000;
18
23
  const CONSOLE_SELECT_THREAD_EVENT = "papyrus:console-select-thread";
19
24
  const LABEL = "font-[family-name:var(--font-sans)] text-[10px] font-extrabold tracking-[0.07em] uppercase";
@@ -33,7 +38,7 @@ function kindLabel(kind: string): KindLabel {
33
38
  }
34
39
 
35
40
  /** One conversation's Messages: loaded once, shown at once when posted, taken back when the save fails. */
36
- function useThreadMessages(source: ConversationsSource, thread: MessageThreadRecord, seed: ConversationMessage[] | undefined, reloadKey: number) {
41
+ function useThreadMessages(source: ConversationsSource, thread: MessageThreadRecord, seed: ConversationMessage[] | undefined, reloadKey: number, viewer: ConversationViewer, onSaved?: (message: ConversationMessage) => void) {
37
42
  const [messages, setMessages] = useState<ConversationMessage[]>(seed ?? []);
38
43
  const [state, setState] = useState<"loading" | "ready" | "error">(seed ? "ready" : "loading");
39
44
  const [error, setError] = useState<string | null>(null);
@@ -61,26 +66,163 @@ function useThreadMessages(source: ConversationsSource, thread: MessageThreadRec
61
66
  try {
62
67
  const saved = await source.post({ thread, body, parentMessageId });
63
68
  setMessages((current) => current.map((message) => (message.id === pendingId ? saved : message)));
69
+ onSaved?.(saved);
64
70
  } catch (failure) {
65
71
  setMessages((current) => current.filter((message) => message.id !== pendingId));
66
72
  throw failure instanceof Error ? failure : new Error("Your message could not be saved.");
67
73
  }
68
- }, [source, thread]);
69
- return { messages, state, error, post, retry: () => setAttempt((current) => current + 1) };
74
+ }, [source, thread, onSaved]);
75
+ // Display names come from the authors' profiles, one request for every author not yet asked about.
76
+ const [names, setNames] = useState<ReadonlyMap<string, string>>(() => new Map());
77
+ const asked = useRef(new Set<string>());
78
+ useEffect(() => {
79
+ const missing = [...new Set(messages.map((message) => message.authorUserProfileId).filter((id): id is string => Boolean(id) && !asked.current.has(id as string)))];
80
+ if (!missing.length) return;
81
+ missing.forEach((id) => asked.current.add(id));
82
+ let active = true;
83
+ source.loadAuthors(missing).then((loaded) => { if (active && loaded.size) setNames((current) => new Map([...current, ...loaded])); }).catch(() => undefined);
84
+ return () => { active = false; };
85
+ }, [messages, source]);
86
+ const edit = useCallback(async (messageId: string, body: string) => {
87
+ const saved = await source.edit(messageId, body);
88
+ setMessages((current) => current.map((message) => (message.id === messageId ? { ...message, ...saved } : message)));
89
+ }, [source]);
90
+ const remove = useCallback(async (messageId: string) => {
91
+ // An admin deleting someone else's message moderates it; everyone else deletes their own.
92
+ const target = messages.find((message) => message.id === messageId);
93
+ const moderate = viewer.isAdmin && target?.authorSub !== viewer.sub;
94
+ const saved = await source.remove(messageId, moderate);
95
+ setMessages((current) => current.map((message) => (message.id === messageId ? { ...message, ...saved } : message)));
96
+ }, [source, messages, viewer]);
97
+ return { messages, state, error, post, edit, remove, names, retry: () => setAttempt((current) => current + 1) };
70
98
  }
71
99
 
72
- function CardCaption({ kind, now, updatedAt }: { kind: string; now: number; updatedAt: string }) {
73
- const { icon: Icon, text } = kindLabel(kind);
100
+ /** Threads that open in the detail here. Forum threads and chats open their own views. */
101
+ function selectableKind(kind: string): boolean {
102
+ const section = sectionOfThreadKind(kind);
103
+ return section === "references" || section === "stories" || kind === "conversation";
104
+ }
105
+
106
+ const CONTROL_FIELD = "block w-full rounded-[var(--feed-radius-md,16px)] border-0 bg-[var(--feed-control)] px-3 py-2 text-sm font-normal leading-5 outline-none placeholder:text-muted-foreground focus-visible:ring-[3px] focus-visible:ring-ring";
107
+ const PILL_QUIET = cn(PILL, "text-muted-foreground hover:bg-[var(--feed-control)]/70 hover:text-foreground");
108
+
109
+ function relativeActivity(updatedAt: string, now: number): string {
74
110
  const age = conversationAge(updatedAt, now);
111
+ return !age ? "" : age === "now" ? "just now" : `${age} ago`;
112
+ }
113
+
114
+ /** What a thread is attached to, as one small line: the Reference's source and headline, or the Story's title and stage. Nothing for an unattached thread. */
115
+ function AttachedLine({ lineageId, references, section, stories }: {
116
+ lineageId: string;
117
+ references: ReadonlyMap<string, ConversationReferenceView>;
118
+ section: ReturnType<typeof sectionOfThreadKind>;
119
+ stories: ReadonlyMap<string, ConversationStoryView>;
120
+ }) {
121
+ if (section === "references") {
122
+ const view = references.get(lineageId);
123
+ if (!view) return null;
124
+ const site = view.preview?.siteName ?? referenceSourceHost(view.reference);
125
+ return (
126
+ <span className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground" data-conversation-attached="reference">
127
+ <BookmarkIcon aria-hidden="true" className="size-3.5 shrink-0" />
128
+ <span className="truncate">{site ? `${site} · ` : ""}{referenceDisplayTitle(view.reference)}</span>
129
+ </span>
130
+ );
131
+ }
132
+ if (section === "stories") {
133
+ const story = stories.get(lineageId);
134
+ if (!story) return null;
135
+ return (
136
+ <span className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground" data-conversation-attached="story">
137
+ <NewspaperIcon aria-hidden="true" className="size-3.5 shrink-0" />
138
+ <span className="truncate">{story.title}{story.stage ? <> · <span data-conversation-stage>Stage: {story.stage}</span></> : null}</span>
139
+ </span>
140
+ );
141
+ }
142
+ return null;
143
+ }
144
+
145
+ /**
146
+ * One conversation as a card (grid) or a line (list). The whole item is one control: a thread opens in the detail, a forum thread
147
+ * opens its own view, a chat opens in the console. Nothing here reads messages: the title, the attachment, the count and the last
148
+ * activity all come from the thread record and the batched headers.
149
+ */
150
+ function ThreadItem({ demo, mode, now, onNavigate, onSelect, references, selected, stories, thread }: {
151
+ demo: boolean;
152
+ mode: "grid" | "list";
153
+ now: number;
154
+ onNavigate?: (href: string) => void;
155
+ onSelect: (threadId: string) => void;
156
+ references: ReadonlyMap<string, ConversationReferenceView>;
157
+ selected: boolean;
158
+ stories: ReadonlyMap<string, ConversationStoryView>;
159
+ thread: MessageThreadRecord;
160
+ }) {
161
+ const section = sectionOfThreadKind(thread.threadKind);
162
+ const { icon: KindIcon, text: kindText } = kindLabel(thread.threadKind);
163
+ const lineageId = thread.primaryAnchorLineageId ?? "";
164
+ const author = threadLastAuthorLabel(thread.metadata);
165
+ const when = relativeActivity(thread.lastMessageAt ?? thread.updatedAt, now);
166
+ const activity = [author ? conversationAuthorName(author) : null, when].filter(Boolean).join(" · ");
167
+ const preview = (thread.summary ?? "").trim();
168
+ const count = thread.messageCount ?? 0;
169
+ const control = "rounded-[var(--feed-radius-lg,10px)] text-left outline-none after:absolute after:inset-0 after:rounded-[inherit] focus-visible:after:ring-[3px] focus-visible:after:ring-ring";
170
+ const opensElsewhere = !selectableKind(thread.threadKind);
171
+ const titleControl = (() => {
172
+ if (section === "chats") {
173
+ return <button className={control} data-conversation-open onClick={() => window.dispatchEvent(new CustomEvent(CONSOLE_SELECT_THREAD_EVENT, { detail: { threadId: thread.id } }))} type="button">{thread.title}</button>;
174
+ }
175
+ if (opensElsewhere) {
176
+ const href = getNewsroomNavHref(`/newsroom/forum/${encodeURIComponent(thread.id)}`, demo);
177
+ return <a className={control} data-conversation-open href={href} onClick={(event) => { if (onNavigate && !event.metaKey && !event.ctrlKey && event.button === 0) { event.preventDefault(); onNavigate(href); } }}>{thread.title}</a>;
178
+ }
179
+ return <button aria-current={selected ? "true" : undefined} className={control} data-conversation-open onClick={() => onSelect(thread.id)} type="button">{thread.title}</button>;
180
+ })();
181
+ const tone = "bg-[var(--feed-card)] hover:bg-[var(--feed-control)]/60";
182
+ // The open thread wears the ring around its outside, as a selected References card does: never a mark on one edge.
183
+ const ring = selected ? SELECTED_RING : undefined;
184
+ const countBadge = (
185
+ <span aria-label={`${count} ${count === 1 ? "message" : "messages"}`} className="inline-flex shrink-0 items-center gap-1 text-sm text-muted-foreground" data-conversation-count>
186
+ <MessageSquareIcon aria-hidden="true" className="size-4" />
187
+ {count}
188
+ </span>
189
+ );
190
+ const marker = selected ? (
191
+ <span className="inline-flex shrink-0 items-center gap-1 text-xs font-semibold text-foreground" data-conversation-selected><ChevronRightIcon aria-hidden="true" className="size-3.5" />Open</span>
192
+ ) : opensElsewhere ? (
193
+ <span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground"><ExternalLinkIcon aria-hidden="true" className="size-3.5" />{section === "chats" ? "Open chat" : "Open thread"}</span>
194
+ ) : null;
195
+
196
+ if (mode === "list") {
197
+ return (
198
+ <article className={cn("relative flex min-w-0 items-center gap-3 rounded-[var(--feed-radius-lg,10px)] px-3 py-2.5 transition-colors", tone)} data-conversation-card={thread.id} data-selected={selected || undefined} style={ring} data-conversation-kind={thread.threadKind} data-conversation-mode="list">
199
+ <KindIcon aria-label={kindText} className="size-4 shrink-0 text-muted-foreground" role="img" />
200
+ <h3 className="min-w-0 flex-1 truncate text-sm font-semibold" data-conversation-title>{titleControl}</h3>
201
+ <span className="hidden min-w-0 max-w-[40%] shrink @[44rem]:flex"><AttachedLine lineageId={lineageId} references={references} section={section} stories={stories} /></span>
202
+ {countBadge}
203
+ {activity ? <span className="hidden shrink-0 truncate text-xs text-muted-foreground @[30rem]:inline" data-conversation-activity>{activity}</span> : null}
204
+ {marker}
205
+ </article>
206
+ );
207
+ }
75
208
  return (
76
- <p className={cn(LABEL, "flex items-center justify-between gap-2 text-muted-foreground")} data-conversation-caption>
77
- <span className="inline-flex items-center gap-1.5"><Icon aria-hidden="true" className="size-3.5" />{text}</span>
78
- {age ? <span className="font-medium normal-case tracking-normal" data-conversation-activity>Active {age === "now" ? "now" : `${age} ago`}</span> : null}
79
- </p>
209
+ <article className={cn("relative flex flex-col gap-2 rounded-[20px] p-4 transition-colors", tone)} data-conversation-card={thread.id} data-selected={selected || undefined} style={ring} data-conversation-kind={thread.threadKind} data-conversation-mode="grid">
210
+ <p className={cn(LABEL, "flex items-center justify-between gap-2 text-muted-foreground")} data-conversation-caption>
211
+ <span className="inline-flex items-center gap-1.5"><KindIcon aria-hidden="true" className="size-3.5" />{kindText}</span>
212
+ {marker}
213
+ </p>
214
+ <h3 className="text-base font-semibold leading-snug" data-conversation-title>{titleControl}</h3>
215
+ <AttachedLine lineageId={lineageId} references={references} section={section} stories={stories} />
216
+ {preview ? <p className="line-clamp-2 text-sm text-muted-foreground" data-conversation-preview>{preview}</p> : null}
217
+ <p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
218
+ {countBadge}
219
+ {activity ? <span className="min-w-0 truncate text-xs text-muted-foreground" data-conversation-activity>{activity}</span> : null}
220
+ </p>
221
+ </article>
80
222
  );
81
223
  }
82
224
 
83
- /** The Reference a conversation is about, as an attachment under its messages: the standard header, no decision controls. */
225
+ /** The Reference a conversation is about, under its title: the standard header, no decision controls, and a link to the Reference. */
84
226
  function ReferenceAttachment({ href, now, onNavigate, thread, view }: { href: string; now: number; onNavigate?: (href: string) => void; thread: MessageThreadRecord; view: ConversationReferenceView | undefined }) {
85
227
  const open = (event: React.MouseEvent<HTMLAnchorElement>) => {
86
228
  if (!onNavigate || event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
@@ -89,62 +231,111 @@ function ReferenceAttachment({ href, now, onNavigate, thread, view }: { href: st
89
231
  };
90
232
  return (
91
233
  <a
92
- aria-label={`Open the Reference: ${view?.reference.title ?? thread.title}`}
234
+ aria-label={`Open the Reference: ${view ? referenceDisplayTitle(view.reference) : thread.title}`}
93
235
  className="flex flex-col overflow-hidden rounded-[var(--feed-radius-lg,10px)] bg-[var(--feed-footer)] text-left outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
94
236
  data-conversation-attachment="reference"
95
237
  href={href}
96
238
  onClick={open}
97
239
  >
98
240
  {view ? <ReferenceCardHeader now={now} preview={view.preview} reference={view.reference} /> : (
99
- <span className="flex items-center gap-2 p-4 text-sm text-muted-foreground"><BookmarkIcon aria-hidden="true" className="size-4" />{thread.title}<ExternalLinkIcon aria-hidden="true" className="size-4" /></span>
241
+ <span className="flex items-center gap-2 p-4 text-sm text-muted-foreground"><BookmarkIcon aria-hidden="true" className="size-4" />Open the Reference<ExternalLinkIcon aria-hidden="true" className="size-4" /></span>
100
242
  )}
101
243
  </a>
102
244
  );
103
245
  }
104
246
 
105
- function ConversationCard({ demo, now, onNavigate, reloadKey, references, seed, source, stories, thread }: {
247
+ /** The conversation's title, with a Rename button that opens an input in place. A refused save says why and keeps what was typed. */
248
+ function ThreadTitle({ canRename, onRename, thread }: { canRename: boolean; onRename: (title: string) => Promise<void>; thread: MessageThreadRecord }) {
249
+ const [editing, setEditing] = useState(false);
250
+ const [draft, setDraft] = useState(thread.title);
251
+ const [saving, setSaving] = useState(false);
252
+ const [error, setError] = useState<string | null>(null);
253
+
254
+ const save = async () => {
255
+ const next = draft.trim();
256
+ if (!next) { setError("A title cannot be empty."); return; }
257
+ if (next === thread.title) { setEditing(false); return; }
258
+ setSaving(true);
259
+ setError(null);
260
+ try {
261
+ await onRename(next);
262
+ setEditing(false);
263
+ } catch (failure) {
264
+ setError(failure instanceof Error ? failure.message : "The title could not be saved.");
265
+ } finally {
266
+ setSaving(false);
267
+ }
268
+ };
269
+ const cancel = () => { setEditing(false); setError(null); setDraft(thread.title); };
270
+
271
+ if (!editing) {
272
+ return (
273
+ <div className="flex items-start justify-between gap-2" data-conversation-heading>
274
+ <h2 className="min-w-0 break-words text-xl font-bold leading-snug tracking-tight" data-conversation-detail-title>{thread.title}</h2>
275
+ {canRename ? (
276
+ <button className={cn(PILL_QUIET, "shrink-0")} data-conversation-rename onClick={() => { setDraft(thread.title); setEditing(true); }} type="button">
277
+ <PencilIcon aria-hidden="true" className="size-4" />Rename
278
+ </button>
279
+ ) : null}
280
+ </div>
281
+ );
282
+ }
283
+ return (
284
+ <div className="space-y-2" data-conversation-heading>
285
+ <input
286
+ aria-label="Conversation title"
287
+ autoFocus
288
+ className={CONTROL_FIELD}
289
+ data-conversation-rename-input
290
+ maxLength={120}
291
+ onChange={(event) => setDraft(event.target.value)}
292
+ onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void save(); } else if (event.key === "Escape") cancel(); }}
293
+ value={draft}
294
+ />
295
+ {error ? (
296
+ <p className="flex items-start gap-1.5 text-sm text-destructive" data-conversation-rename-error role="alert">
297
+ <TriangleAlertIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
298
+ <span>Not renamed: {error} Your text is kept here.</span>
299
+ </p>
300
+ ) : null}
301
+ <div className="flex flex-wrap gap-2">
302
+ <button className={cn(PILL, "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]")} data-conversation-rename-save disabled={saving || !draft.trim()} onClick={() => void save()} type="button">
303
+ {saving ? <Loader2Icon aria-hidden="true" className="size-4 animate-spin" /> : <CheckIcon aria-hidden="true" className="size-4" />}Save
304
+ </button>
305
+ <button className={PILL_QUIET} data-conversation-rename-cancel onClick={cancel} type="button"><XIcon aria-hidden="true" className="size-4" />Cancel</button>
306
+ </div>
307
+ </div>
308
+ );
309
+ }
310
+
311
+ /** One conversation opened: its own title, what it is about, and every message with its replies. Messages are read only now. */
312
+ function ThreadDetail({ demo, now, onNavigate, onPosted, onRename, reloadKey, references, seed, source, stories, thread, viewer }: {
106
313
  demo: boolean;
107
314
  now: number;
108
315
  onNavigate?: (href: string) => void;
316
+ onPosted: (thread: MessageThreadRecord, message: ConversationMessage) => void;
317
+ onRename: (thread: MessageThreadRecord, title: string) => Promise<void>;
109
318
  reloadKey: number;
110
319
  references: ReadonlyMap<string, ConversationReferenceView>;
111
320
  seed: ConversationMessage[] | undefined;
112
321
  source: ConversationsSource;
113
322
  stories: ReadonlyMap<string, ConversationStoryView>;
114
323
  thread: MessageThreadRecord;
324
+ viewer: ConversationViewer;
115
325
  }) {
116
326
  const section = sectionOfThreadKind(thread.threadKind);
117
327
  const lineageId = thread.primaryAnchorLineageId ?? "";
118
- // Forum threads and chats keep their own detail views; every other conversation is read and answered right here.
119
- const inline = section === "references" || section === "stories" || thread.threadKind === "conversation";
120
- const messages = useThreadMessages(source, thread, seed, reloadKey);
121
328
  const story = stories.get(lineageId);
122
- const frame = "flex flex-col gap-3 rounded-[20px] bg-[var(--feed-card)] p-4";
123
-
124
- if (!inline) {
125
- const isChat = section === "chats";
126
- const href = getNewsroomNavHref(`/newsroom/forum/${encodeURIComponent(thread.id)}`, demo);
127
- return (
128
- <article className={frame} data-conversation-card={thread.id} data-conversation-kind={thread.threadKind}>
129
- <CardCaption kind={thread.threadKind} now={now} updatedAt={thread.updatedAt} />
130
- <h3 className="text-base font-semibold leading-snug" data-conversation-title>{thread.title}</h3>
131
- <p className="text-sm text-muted-foreground">{messageCountLabel(thread.messageCount ?? 0)}</p>
132
- {isChat ? (
133
- <button className={cn(PILL, "self-start bg-[var(--feed-control)] text-foreground")} data-conversation-open onClick={() => window.dispatchEvent(new CustomEvent(CONSOLE_SELECT_THREAD_EVENT, { detail: { threadId: thread.id } }))} type="button">
134
- <MessageCircleIcon aria-hidden="true" className="size-4" />Open chat
135
- </button>
136
- ) : (
137
- <a className={cn(PILL, "self-start bg-[var(--feed-control)] text-foreground")} data-conversation-open href={href} onClick={(event) => { if (onNavigate && !event.metaKey && !event.ctrlKey && event.button === 0) { event.preventDefault(); onNavigate(href); } }}>
138
- <MessagesSquareIcon aria-hidden="true" className="size-4" />Open thread
139
- </a>
140
- )}
141
- </article>
142
- );
143
- }
144
-
329
+ const saved = useCallback((message: ConversationMessage) => onPosted(thread, message), [onPosted, thread]);
330
+ const messages = useThreadMessages(source, thread, seed, reloadKey, viewer, saved);
331
+ const { icon: KindIcon, text: kindText } = kindLabel(thread.threadKind);
145
332
  return (
146
- <article className={frame} data-conversation-card={thread.id} data-conversation-kind={thread.threadKind}>
147
- <CardCaption kind={thread.threadKind} now={now} updatedAt={thread.updatedAt} />
333
+ <div className="space-y-4" data-conversation-detail={thread.id}>
334
+ <p className={cn(LABEL, "inline-flex items-center gap-1.5 text-muted-foreground")}><KindIcon aria-hidden="true" className="size-3.5" />{kindText}</p>
335
+ <ThreadTitle canRename={canRenameThread(viewer, thread)} onRename={(title) => onRename(thread, title)} thread={thread} />
336
+ {section === "references" && lineageId ? (
337
+ <ReferenceAttachment href={getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(lineageId)}`, demo)} now={now} onNavigate={onNavigate} thread={thread} view={references.get(lineageId)} />
338
+ ) : null}
148
339
  {section === "stories" ? (
149
340
  <div className="space-y-1 rounded-[var(--feed-radius-lg,10px)] bg-[var(--feed-footer)] p-3" data-conversation-story>
150
341
  <p className="text-base font-semibold leading-snug">{story?.title ?? thread.title}</p>
@@ -157,26 +348,79 @@ function ConversationCard({ demo, now, onNavigate, reloadKey, references, seed,
157
348
  </div>
158
349
  ) : null}
159
350
  <ConversationThread
351
+ authorNames={messages.names}
160
352
  error={messages.error}
161
353
  heading={`Messages in ${thread.title}`}
162
354
  hideHeading
163
- latest={LATEST_MESSAGES}
164
355
  messages={messages.messages}
165
356
  now={now}
357
+ onDelete={messages.remove}
358
+ onEdit={messages.edit}
166
359
  onPost={messages.post}
167
360
  onRetry={messages.retry}
168
361
  state={messages.state}
362
+ viewer={viewer}
169
363
  />
170
- {section === "references" && lineageId ? (
171
- <ReferenceAttachment href={getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(lineageId)}`, demo)} now={now} onNavigate={onNavigate} thread={thread} view={references.get(lineageId)} />
172
- ) : null}
173
- </article>
364
+ </div>
174
365
  );
175
366
  }
176
367
 
368
+ /** The New thread form, shown in the detail view: a required title and the first message. */
369
+ function NewConversation({ onCancel, onStart }: { onCancel: () => void; onStart: (title: string, body: string) => Promise<void> }) {
370
+ const [title, setTitle] = useState("");
371
+ return (
372
+ <section aria-label="New thread" className="space-y-3" data-conversation-new>
373
+ <div className="flex items-center justify-between gap-2">
374
+ <h2 className="text-lg font-semibold">New thread</h2>
375
+ <button className={cn(PILL, "bg-[var(--feed-control)]/45 text-muted-foreground hover:text-foreground")} data-conversation-new-cancel onClick={onCancel} type="button">
376
+ <XIcon aria-hidden="true" className="size-4" />
377
+ Cancel
378
+ </button>
379
+ </div>
380
+ <input
381
+ aria-label="Title"
382
+ className={CONTROL_FIELD}
383
+ data-conversation-new-title
384
+ maxLength={120}
385
+ onChange={(event) => setTitle(event.target.value)}
386
+ placeholder="Title (required)"
387
+ required
388
+ value={title}
389
+ />
390
+ <Composer
391
+ buttonLabel="Post"
392
+ icon="post"
393
+ label="New message"
394
+ onSubmit={async (body) => { await onStart(title.trim(), body); setTitle(""); }}
395
+ placeholder="The first message… (a thought that is not about any one Reference or Story)"
396
+ postBlockedReason={title.trim() ? null : "Add a title to post."}
397
+ />
398
+ </section>
399
+ );
400
+ }
401
+
402
+ function readLayoutPreference(): "grid" | "list" {
403
+ try { return window.localStorage.getItem(LAYOUT_STORAGE_KEY) === "list" ? "list" : "grid"; } catch { return "grid"; }
404
+ }
405
+ function writeLayoutPreference(mode: "grid" | "list") {
406
+ try { window.localStorage.setItem(LAYOUT_STORAGE_KEY, mode); } catch { /* remembered for this visit only */ }
407
+ }
408
+ function readSelectedThread(): string | null {
409
+ try { return new URLSearchParams(window.location.search).get(THREAD_PARAM) || null; } catch { return null; }
410
+ }
411
+ function writeSelectedThread(threadId: string | null) {
412
+ try {
413
+ const url = new URL(window.location.href);
414
+ if (threadId) url.searchParams.set(THREAD_PARAM, threadId); else url.searchParams.delete(THREAD_PARAM);
415
+ window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`);
416
+ } catch { /* the address stays as it is */ }
417
+ }
418
+
177
419
  /**
178
- * Every conversation, newest activity first, as cards in a grid. Messages lead each card; the Reference or Story it is about is an
179
- * attachment under them. Sections are buttons. Reads page by page from the thread indexes: nothing is loaded that is not shown.
420
+ * Every conversation, newest activity first, like a web forum: each thread is one card (grid) or one line (list) with its own title.
421
+ * Opening one shows its whole conversation: beside the list from 1024px, in its place (with Back) below it. The address holds the
422
+ * selection (`?thread=`). Sections are buttons. Reads are one page of thread records plus one batch of headers; messages are read
423
+ * only for the open thread.
180
424
  */
181
425
  export function ConversationsView({ demo = false, onNavigate, source }: {
182
426
  demo?: boolean;
@@ -185,6 +429,11 @@ export function ConversationsView({ demo = false, onNavigate, source }: {
185
429
  source: ConversationsSource;
186
430
  }) {
187
431
  const [section, setSection] = useState<ConversationSection>("all");
432
+ const [mode, setMode] = useState<"grid" | "list">("grid");
433
+ const [selectedId, setSelectedId] = useState<string | null>(null);
434
+ const [fetched, setFetched] = useState<MessageThreadRecord | null>(null);
435
+ const [notice, setNotice] = useState<string | null>(null);
436
+ const [viewer, setViewer] = useState<ConversationViewer>(NO_VIEWER);
188
437
  const [threads, setThreads] = useState<MessageThreadRecord[]>([]);
189
438
  const [seeds, setSeeds] = useState<Record<string, ConversationMessage[]>>({});
190
439
  const [loading, setLoading] = useState(true);
@@ -206,6 +455,21 @@ export function ConversationsView({ demo = false, onNavigate, source }: {
206
455
  if (storyIds.length) void source.loadStories(storyIds).then((loaded) => setStories((current) => new Map([...current, ...loaded]))).catch(() => undefined);
207
456
  }, [source]);
208
457
 
458
+ useEffect(() => {
459
+ let active = true;
460
+ source.loadViewer().then((loaded) => { if (active) setViewer(loaded); }).catch(() => undefined);
461
+ return () => { active = false; };
462
+ }, [source]);
463
+
464
+ // Preferences and the address are read in the browser only, after the first render.
465
+ useEffect(() => {
466
+ setMode(readLayoutPreference());
467
+ setSelectedId(readSelectedThread());
468
+ const onPop = () => setSelectedId(readSelectedThread());
469
+ window.addEventListener("popstate", onPop);
470
+ return () => window.removeEventListener("popstate", onPop);
471
+ }, []);
472
+
209
473
  useEffect(() => {
210
474
  const mine = ++generation.current;
211
475
  stream.current = createMergedThreadStream(threadKindsForSection(section), source.loadThreads);
@@ -234,6 +498,54 @@ export function ConversationsView({ demo = false, onNavigate, source }: {
234
498
  return () => { window.clearInterval(timer); window.removeEventListener("focus", refocus); document.removeEventListener("visibilitychange", refocus); };
235
499
  }, []);
236
500
 
501
+ // A linked thread that is not on the pages read so far is read by its id.
502
+ const inList = selectedId ? threads.find((thread) => thread.id === selectedId) : undefined;
503
+ useEffect(() => {
504
+ if (!selectedId || selectedId === NEW_THREAD || loading || inList || fetched?.id === selectedId) return;
505
+ let active = true;
506
+ const dropSelection = (message: string) => { setNotice(message); setSelectedId(null); writeSelectedThread(null); };
507
+ source.loadThread(selectedId).then((thread) => {
508
+ if (!active) return;
509
+ if (thread && selectableKind(thread.threadKind)) { setFetched(thread); enrich([thread]); return; }
510
+ dropSelection("That conversation could not be found.");
511
+ }).catch((failure) => {
512
+ if (active) dropSelection(`That conversation could not be opened. ${failure instanceof Error ? failure.message : ""}`.trim());
513
+ });
514
+ return () => { active = false; };
515
+ }, [enrich, fetched?.id, inList, loading, selectedId, source]);
516
+
517
+ const selected = selectedId ? (inList ?? (fetched?.id === selectedId ? fetched : undefined)) : undefined;
518
+ const openable = selected && selectableKind(selected.threadKind) ? selected : undefined;
519
+
520
+ const select = (threadId: string | null) => { setNotice(null); setSelectedId(threadId); writeSelectedThread(threadId); };
521
+ const chooseMode = (next: "grid" | "list") => { setMode(next); writeLayoutPreference(next); };
522
+
523
+ const patchThread = useCallback((id: string, change: (thread: MessageThreadRecord) => MessageThreadRecord, moveToTop = false) => {
524
+ setFetched((current) => (current?.id === id ? change(current) : current));
525
+ setThreads((current) => {
526
+ const index = current.findIndex((thread) => thread.id === id);
527
+ if (index < 0) return current;
528
+ const changed = change(current[index]);
529
+ return moveToTop ? [changed, ...current.filter((_, at) => at !== index)] : current.map((thread, at) => (at === index ? changed : thread));
530
+ });
531
+ }, []);
532
+
533
+ const onPosted = useCallback((thread: MessageThreadRecord, message: ConversationMessage) => {
534
+ patchThread(thread.id, (current) => ({
535
+ ...current,
536
+ messageCount: (current.messageCount ?? 0) + 1,
537
+ lastMessageAt: message.createdAt,
538
+ updatedAt: message.createdAt,
539
+ summary: conversationPreviewLine(message.content),
540
+ metadata: threadMetadataWithLastAuthor(current.metadata, source.authorLabel),
541
+ }), true);
542
+ }, [patchThread, source.authorLabel]);
543
+
544
+ const rename = async (thread: MessageThreadRecord, title: string) => {
545
+ const saved = await source.rename(thread, title);
546
+ patchThread(thread.id, (current) => ({ ...current, title: saved.title }));
547
+ };
548
+
237
549
  const loadMore = async () => {
238
550
  if (!stream.current || loadingMore) return;
239
551
  const mine = generation.current;
@@ -251,42 +563,74 @@ export function ConversationsView({ demo = false, onNavigate, source }: {
251
563
  }
252
564
  };
253
565
 
254
- const startConversation = async (body: string) => {
255
- const { thread, message } = await source.start(body);
566
+ const startConversation = async (title: string, body: string) => {
567
+ const { thread, message } = await source.start(title, body);
256
568
  setSeeds((current) => ({ ...current, [thread.id]: [message] }));
257
569
  setThreads((current) => [thread, ...current.filter((entry) => entry.id !== thread.id)]);
570
+ select(thread.id); // the new thread opens in the detail view
258
571
  };
572
+ const composing = selectedId === NEW_THREAD;
259
573
 
260
574
  const canStart = section === "all" || section === "other";
261
- return (
262
- <div className="@container space-y-5" data-conversations-view {...FEED_SURFACE_PROPS}>
263
- <div aria-label="Conversations" className="flex flex-wrap gap-1.5" data-conversation-sections role="group">
264
- {CONVERSATION_SECTIONS.map(({ key, label }) => {
265
- const Icon = SECTION_ICONS[key];
266
- const on = section === key;
267
- return (
575
+ const list = (
576
+ <div className="@container space-y-5" data-conversations-list>
577
+ <div className="flex flex-wrap items-center justify-between gap-3">
578
+ <div aria-label="Conversations" className="flex flex-wrap gap-1.5" data-conversation-sections role="group">
579
+ {CONVERSATION_SECTIONS.map(({ key, label }) => {
580
+ const Icon = SECTION_ICONS[key];
581
+ const on = section === key;
582
+ return (
583
+ <button
584
+ aria-pressed={on}
585
+ className={cn(PILL, on ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)]/45 text-muted-foreground hover:bg-[var(--feed-control)]/70 hover:text-foreground")}
586
+ data-conversation-section={key}
587
+ key={key}
588
+ onClick={() => setSection(key)}
589
+ type="button"
590
+ >
591
+ <Icon aria-hidden="true" className="size-4" />
592
+ {label}
593
+ </button>
594
+ );
595
+ })}
596
+ </div>
597
+ <div className="flex flex-wrap items-center gap-1.5">
598
+ {canStart ? (
599
+ <button
600
+ aria-pressed={composing}
601
+ className={cn(PILL, composing ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)] text-foreground hover:bg-[var(--feed-control)]/70")}
602
+ data-conversation-new-open
603
+ onClick={() => select(NEW_THREAD)}
604
+ type="button"
605
+ >
606
+ <SquarePenIcon aria-hidden="true" className="size-4" />
607
+ New thread
608
+ </button>
609
+ ) : null}
610
+ <div aria-label="Layout" className="flex gap-1.5" data-conversation-layouts role="group">
611
+ {([["grid", "Grid", LayoutGridIcon], ["list", "List", ListIcon]] as const).map(([key, label, Icon]) => (
268
612
  <button
269
- aria-pressed={on}
270
- className={cn(PILL, on ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)]/45 text-muted-foreground hover:bg-[var(--feed-control)]/70 hover:text-foreground")}
271
- data-conversation-section={key}
613
+ aria-pressed={mode === key}
614
+ className={cn(PILL, mode === key ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)]/45 text-muted-foreground hover:bg-[var(--feed-control)]/70 hover:text-foreground")}
615
+ data-conversation-layout={key}
272
616
  key={key}
273
- onClick={() => setSection(key)}
617
+ onClick={() => chooseMode(key)}
274
618
  type="button"
275
619
  >
276
620
  <Icon aria-hidden="true" className="size-4" />
277
621
  {label}
278
622
  </button>
279
- );
280
- })}
623
+ ))}
624
+ </div>
625
+ </div>
281
626
  </div>
282
627
 
283
- {canStart ? (
284
- <section aria-label="New message" className="max-w-2xl space-y-2 rounded-[20px] bg-[var(--feed-card)] p-4" data-conversation-new>
285
- <h2 className={cn(LABEL, "text-muted-foreground")}>New message</h2>
286
- <Composer buttonLabel="Post" icon="post" label="New message" onSubmit={startConversation} placeholder="New message… (a thought that is not about any one Reference or Story)" />
287
- </section>
628
+ {notice ? (
629
+ <p className="flex items-start gap-1.5 text-sm text-muted-foreground" data-conversations-notice role="status">
630
+ <TriangleAlertIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
631
+ <span>{notice}</span>
632
+ </p>
288
633
  ) : null}
289
-
290
634
  {loading ? <p className="flex items-center gap-1.5 text-sm text-muted-foreground" data-conversations-loading role="status"><Loader2Icon aria-hidden="true" className="size-4 animate-spin" />Loading conversations</p> : null}
291
635
  {error ? (
292
636
  <p className="flex items-start gap-1.5 text-sm text-destructive" data-conversations-error role="alert">
@@ -302,9 +646,10 @@ export function ConversationsView({ demo = false, onNavigate, source }: {
302
646
  ) : null}
303
647
 
304
648
  {threads.length ? (
305
- <div className={FEED_GRID} data-conversation-grid>
649
+ // p-1 leaves room for the selection ring, which sits 4px outside a card and would be cut off at the column's edge.
650
+ <div className={cn(mode === "grid" ? FEED_GRID : "flex flex-col gap-2", "p-1")} data-conversation-grid={mode === "grid" ? "" : undefined} data-conversation-lines={mode === "list" ? "" : undefined}>
306
651
  {threads.map((thread) => (
307
- <ConversationCard demo={demo} key={thread.id} now={now} onNavigate={onNavigate} reloadKey={reloadKey} references={references} seed={seeds[thread.id]} source={source} stories={stories} thread={thread} />
652
+ <ThreadItem demo={demo} key={thread.id} mode={mode} now={now} onNavigate={onNavigate} onSelect={select} references={references} selected={thread.id === openable?.id} stories={stories} thread={thread} />
308
653
  ))}
309
654
  </div>
310
655
  ) : null}
@@ -316,4 +661,20 @@ export function ConversationsView({ demo = false, onNavigate, source }: {
316
661
  ) : null}
317
662
  </div>
318
663
  );
664
+
665
+ return (
666
+ <div data-conversations-view data-conversations-mode={mode} {...FEED_SURFACE_PROPS}>
667
+ <ListDetailLayout
668
+ backLabel="Messages"
669
+ detail={composing ? (
670
+ <NewConversation onCancel={() => select(null)} onStart={startConversation} />
671
+ ) : openable ? (
672
+ <ThreadDetail demo={demo} key={openable.id} now={now} onNavigate={onNavigate} onPosted={onPosted} onRename={rename} reloadKey={reloadKey} references={references} seed={seeds[openable.id]} source={source} stories={stories} thread={openable} viewer={viewer} />
673
+ ) : null}
674
+ list={list}
675
+ onBack={() => select(null)}
676
+ open={composing || Boolean(openable)}
677
+ />
678
+ </div>
679
+ );
319
680
  }