ltcai 6.7.0 → 7.1.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 (65) hide show
  1. package/README.md +29 -31
  2. package/docs/CHANGELOG.md +48 -0
  3. package/frontend/src/App.tsx +80 -0
  4. package/frontend/src/api/client.ts +11 -0
  5. package/frontend/src/components/AdminAccessGate.tsx +70 -0
  6. package/frontend/src/components/BrainConversation.tsx +232 -8
  7. package/frontend/src/components/FeedbackState.tsx +45 -0
  8. package/frontend/src/components/WorkspaceProfileSwitcher.tsx +176 -0
  9. package/frontend/src/components/onboarding/AnalysisScreen.tsx +20 -12
  10. package/frontend/src/components/onboarding/InstallScreen.tsx +55 -0
  11. package/frontend/src/components/onboarding/RecommendationScreen.tsx +55 -5
  12. package/frontend/src/components/onboarding/recommendationModel.ts +59 -2
  13. package/frontend/src/features/brain/BrainConversation.tsx +382 -7
  14. package/frontend/src/features/brain/BrainGraphLayer.tsx +258 -25
  15. package/frontend/src/features/brain/BrainHome.tsx +300 -1
  16. package/frontend/src/features/brain/brainData.ts +25 -1
  17. package/frontend/src/features/brain/graphLayout.ts +12 -1
  18. package/frontend/src/features/brain/types.ts +54 -0
  19. package/frontend/src/i18n.ts +374 -0
  20. package/frontend/src/store/appStore.ts +15 -0
  21. package/frontend/src/styles.css +1383 -41
  22. package/lattice_brain/__init__.py +1 -1
  23. package/lattice_brain/runtime/multi_agent.py +1 -1
  24. package/latticeai/__init__.py +1 -1
  25. package/latticeai/api/workspace.py +59 -0
  26. package/latticeai/core/marketplace.py +1 -1
  27. package/latticeai/core/workspace_os.py +1 -1
  28. package/package.json +1 -1
  29. package/scripts/brain_quality_eval.py +118 -0
  30. package/src-tauri/Cargo.lock +1 -1
  31. package/src-tauri/Cargo.toml +1 -1
  32. package/src-tauri/tauri.conf.json +1 -1
  33. package/static/app/asset-manifest.json +28 -28
  34. package/static/app/assets/Act-BHEb8bAM.js +2 -0
  35. package/static/app/assets/{Act-DGN37eR4.js.map → Act-BHEb8bAM.js.map} +1 -1
  36. package/static/app/assets/Brain-yvR7xkrV.js +322 -0
  37. package/static/app/assets/Brain-yvR7xkrV.js.map +1 -0
  38. package/static/app/assets/Capture-BrAmsSEH.js +2 -0
  39. package/static/app/assets/Capture-BrAmsSEH.js.map +1 -0
  40. package/static/app/assets/Library-BOVzYfxI.js +2 -0
  41. package/static/app/assets/{Library-BbagRFyd.js.map → Library-BOVzYfxI.js.map} +1 -1
  42. package/static/app/assets/System-D4WaN4kj.js +2 -0
  43. package/static/app/assets/System-D4WaN4kj.js.map +1 -0
  44. package/static/app/assets/index-BOB-W1FZ.js +17 -0
  45. package/static/app/assets/index-BOB-W1FZ.js.map +1 -0
  46. package/static/app/assets/index-DvLsGy-z.css +2 -0
  47. package/static/app/assets/primitives-C3_BfUb8.js +2 -0
  48. package/static/app/assets/primitives-C3_BfUb8.js.map +1 -0
  49. package/static/app/assets/textarea-CbvhHvzg.js +2 -0
  50. package/static/app/assets/{textarea-C2PLu6_O.js.map → textarea-CbvhHvzg.js.map} +1 -1
  51. package/static/app/index.html +2 -2
  52. package/static/app/assets/Act-DGN37eR4.js +0 -2
  53. package/static/app/assets/Brain-HDcIwPHW.js +0 -322
  54. package/static/app/assets/Brain-HDcIwPHW.js.map +0 -1
  55. package/static/app/assets/Capture--eZfb_Ex.js +0 -2
  56. package/static/app/assets/Capture--eZfb_Ex.js.map +0 -1
  57. package/static/app/assets/Library-BbagRFyd.js +0 -2
  58. package/static/app/assets/System-C6pd3Mp_.js +0 -2
  59. package/static/app/assets/System-C6pd3Mp_.js.map +0 -1
  60. package/static/app/assets/index-C950aES_.js +0 -17
  61. package/static/app/assets/index-C950aES_.js.map +0 -1
  62. package/static/app/assets/index-ztppMg0c.css +0 -2
  63. package/static/app/assets/primitives-Bcm5lZRu.js +0 -2
  64. package/static/app/assets/primitives-Bcm5lZRu.js.map +0 -1
  65. package/static/app/assets/textarea-C2PLu6_O.js +0 -2
@@ -3,13 +3,17 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
3
  import { ImagePlus, MessageSquare, Plus, Send, Trash2 } from "lucide-react";
4
4
  import { latticeApi } from "@/api/client";
5
5
  import { EmptyState, EntityList, SourceBadge, StructuredView } from "@/components/primitives";
6
+ import { FeedbackState } from "@/components/FeedbackState";
6
7
  import { type BrainState, LivingBrain } from "@/components/LivingBrain";
7
8
  import { Badge } from "@/components/ui/badge";
8
9
  import { Button } from "@/components/ui/button";
9
10
  import { Textarea } from "@/components/ui/textarea";
10
11
  import { asArray } from "@/lib/utils";
12
+ import { t, type Language } from "@/i18n";
13
+ import { useAppStore } from "@/store/appStore";
11
14
 
12
- type Msg = { role?: string; content?: string; timestamp?: string };
15
+ type Citation = { id: string; source: string; title: string; snippet: string; score: number | null };
16
+ type Msg = { role?: string; content?: string; timestamp?: string; citations?: Citation[] };
13
17
  type BrainActivity = BrainState;
14
18
  type BrainVitals = {
15
19
  connected: boolean;
@@ -49,7 +53,58 @@ function usageNumber(data: unknown, key: string) {
49
53
  return Number.isFinite(value) ? value : null;
50
54
  }
51
55
 
56
+ // Pull human-readable citation records out of the heterogeneous streaming trace.
57
+ // The backend trace shape varies, so we defensively scan the common containers
58
+ // (matches / sources / retrieved / context / chunks) for source-like records.
59
+ function extractCitations(trace: unknown): Citation[] {
60
+ if (!trace || typeof trace !== "object") return [];
61
+ const record = trace as Record<string, unknown>;
62
+ const containerKeys = ["matches", "sources", "retrieved", "context", "chunks", "citations", "evidence"];
63
+ let rows: Array<Record<string, unknown>> = [];
64
+ for (const key of containerKeys) {
65
+ const candidate = asArray<Record<string, unknown>>(record[key]);
66
+ if (candidate.length) {
67
+ rows = candidate;
68
+ break;
69
+ }
70
+ }
71
+ if (!rows.length && Array.isArray(trace)) rows = trace as Array<Record<string, unknown>>;
72
+
73
+ const seen = new Set<string>();
74
+ const citations: Citation[] = [];
75
+ for (const row of rows) {
76
+ if (!row || typeof row !== "object") continue;
77
+ const title = String(row.title || row.name || row.heading || row.label || row.id || "").trim();
78
+ const source = String(row.source || row.type || row.origin || row.kind || row.provider || "memory").trim();
79
+ const snippet = String(row.snippet || row.text || row.content || row.summary || row.excerpt || "").trim();
80
+ if (!title && !snippet) continue;
81
+ const scoreRaw = Number(row.score ?? row.relevance ?? row.similarity);
82
+ const score = Number.isFinite(scoreRaw) ? Math.round((scoreRaw <= 1 ? scoreRaw * 100 : scoreRaw)) : null;
83
+ const id = String(row.id || `${source}:${title}:${snippet.slice(0, 24)}`);
84
+ if (seen.has(id)) continue;
85
+ seen.add(id);
86
+ citations.push({ id, source: source || "memory", title: title || snippet.slice(0, 48), snippet, score });
87
+ if (citations.length >= 6) break;
88
+ }
89
+ return citations;
90
+ }
91
+
92
+ // Anchor citation chips after the first N sentences (conservative heuristic).
93
+ // Each segment carries an optional citation index so chips stay tied to text.
94
+ function splitWithCitations(content: string, citationCount: number): Array<{ text: string; citation: number | null }> {
95
+ if (!citationCount || !content) return [{ text: content, citation: null }];
96
+ const sentences = content.match(/[^.!?。!?\n]+[.!?。!?]?\s*/g);
97
+ if (!sentences || sentences.length <= 1) return [{ text: content, citation: 0 }];
98
+ const segments: Array<{ text: string; citation: number | null }> = [];
99
+ sentences.forEach((sentence, index) => {
100
+ const citation = index < citationCount ? index : null;
101
+ segments.push({ text: sentence, citation });
102
+ });
103
+ return segments;
104
+ }
105
+
52
106
  export function BrainConversation({ className }: { className?: string }) {
107
+ const language = useAppStore((state) => state.language);
53
108
  const qc = useQueryClient();
54
109
  const history = useQuery({ queryKey: ["chatHistory"], queryFn: latticeApi.chatHistory });
55
110
  const models = useQuery({ queryKey: ["models"], queryFn: latticeApi.models });
@@ -94,7 +149,20 @@ export function BrainConversation({ className }: { className?: string }) {
94
149
  return next;
95
150
  });
96
151
  },
97
- onTrace: setTrace,
152
+ onTrace: (incoming) => {
153
+ setTrace(incoming);
154
+ const citations = extractCitations(incoming);
155
+ if (citations.length) {
156
+ setMessages((items) => {
157
+ const next = [...items];
158
+ const last = next[next.length - 1];
159
+ if (last && last.role === "assistant") {
160
+ next[next.length - 1] = { ...last, citations };
161
+ }
162
+ return next;
163
+ });
164
+ }
165
+ },
98
166
  },
99
167
  );
100
168
  if (result.error) {
@@ -167,7 +235,15 @@ export function BrainConversation({ className }: { className?: string }) {
167
235
  <div key={`${msg.role || "message"}-${index}`} className={`brain-message-row ${msg.role === "user" ? "from-user" : "from-brain"}`}>
168
236
  <div className="brain-message-bubble">
169
237
  <div className="brain-message-role">{msg.role === "user" ? "You" : "Brain"}</div>
170
- <div className="whitespace-pre-wrap">{msg.content}</div>
238
+ {msg.role === "user" ? (
239
+ <div className="whitespace-pre-wrap">{msg.content}</div>
240
+ ) : (
241
+ <MessageWithCitations
242
+ language={language}
243
+ content={msg.content || ""}
244
+ citations={msg.citations || []}
245
+ />
246
+ )}
171
247
  </div>
172
248
  </div>
173
249
  )) : (
@@ -239,6 +315,122 @@ export function BrainConversation({ className }: { className?: string }) {
239
315
  );
240
316
  }
241
317
 
318
+ // Renders assistant text with inline, keyboard-accessible citation chips that are
319
+ // linked to a proof list below. Selecting a chip highlights and reveals its source.
320
+ function MessageWithCitations({
321
+ language,
322
+ content,
323
+ citations,
324
+ }: {
325
+ language: Language;
326
+ content: string;
327
+ citations: Citation[];
328
+ }) {
329
+ const [activeId, setActiveId] = React.useState<string | null>(null);
330
+ const [announce, setAnnounce] = React.useState("");
331
+ const listRef = React.useRef<HTMLOListElement | null>(null);
332
+
333
+ if (!citations.length) {
334
+ return <div className="whitespace-pre-wrap">{content}</div>;
335
+ }
336
+
337
+ const segments = splitWithCitations(content, citations.length);
338
+
339
+ const focusListItem = (id: string) => {
340
+ const node = listRef.current?.querySelector<HTMLLIElement>(`[data-citation-target="${id}"]`);
341
+ node?.focus?.();
342
+ node?.scrollIntoView?.({ block: "nearest" });
343
+ };
344
+
345
+ const toggle = (citation: Citation) => {
346
+ setActiveId((prev) => {
347
+ const next = prev === citation.id ? null : citation.id;
348
+ if (next) {
349
+ const idx = citations.findIndex((c) => c.id === citation.id) + 1;
350
+ setAnnounce(t(language, "brain.citation.announce.open", { num: idx, title: citation.title }));
351
+ focusListItem(citation.id);
352
+ } else {
353
+ setAnnounce(t(language, "brain.citation.announce.close"));
354
+ }
355
+ return next;
356
+ });
357
+ };
358
+
359
+ const onChipKey = (event: React.KeyboardEvent<HTMLSpanElement>, citation: Citation) => {
360
+ if (event.key === "Enter" || event.key === " " || event.key === "Spacebar") {
361
+ event.preventDefault();
362
+ toggle(citation);
363
+ } else if (event.key === "Escape" && activeId) {
364
+ event.preventDefault();
365
+ setActiveId(null);
366
+ setAnnounce(t(language, "brain.citation.announce.close"));
367
+ }
368
+ };
369
+
370
+ return (
371
+ <div className="brain-message-cited">
372
+ <div className="whitespace-pre-wrap brain-message-text">
373
+ {segments.map((segment, index) => {
374
+ const citation = segment.citation != null ? citations[segment.citation] : null;
375
+ const num = segment.citation != null ? segment.citation + 1 : 0;
376
+ return (
377
+ <React.Fragment key={index}>
378
+ {segment.text}
379
+ {citation ? (
380
+ <span
381
+ role="button"
382
+ tabIndex={0}
383
+ className={`citation-chip ${activeId === citation.id ? "is-active" : ""}`}
384
+ data-citation-id={citation.id}
385
+ aria-pressed={activeId === citation.id}
386
+ aria-label={t(language, "brain.citation.chip.long", { num, title: citation.title })}
387
+ onClick={() => toggle(citation)}
388
+ onKeyDown={(event) => onChipKey(event, citation)}
389
+ >
390
+ {t(language, "brain.citation.chip", { num })}
391
+ </span>
392
+ ) : null}
393
+ </React.Fragment>
394
+ );
395
+ })}
396
+ </div>
397
+
398
+ <div className="brain-answer-proof" aria-label={t(language, "brain.citation.region")}>
399
+ <div className="brain-answer-proof-head">
400
+ <span>{t(language, "brain.answerProof.title")}</span>
401
+ <strong>{t(language, "brain.citation.count", { count: citations.length })}</strong>
402
+ </div>
403
+ <ol ref={listRef}>
404
+ {citations.map((citation, index) => {
405
+ const isActive = activeId === citation.id;
406
+ return (
407
+ <li
408
+ key={citation.id}
409
+ data-citation-target={citation.id}
410
+ className={isActive ? "is-cited" : ""}
411
+ tabIndex={-1}
412
+ aria-current={isActive ? "true" : undefined}
413
+ >
414
+ <span>{t(language, "brain.citation.preview.from")}{citation.source}</span>
415
+ <strong>{t(language, "brain.citation.chip", { num: index + 1 })} · {citation.title}</strong>
416
+ {citation.snippet ? (
417
+ <small>{t(language, "brain.citation.preview.snippet")}{citation.snippet}</small>
418
+ ) : null}
419
+ {citation.score != null ? (
420
+ <small className="brain-citation-score">{t(language, "brain.citation.score", { score: citation.score })}</small>
421
+ ) : null}
422
+ </li>
423
+ );
424
+ })}
425
+ </ol>
426
+ <small className="brain-citation-tip">{t(language, "brain.citation.keyboard.tip")}</small>
427
+ </div>
428
+
429
+ <div className="sr-only" role="status" aria-live="polite">{announce}</div>
430
+ </div>
431
+ );
432
+ }
433
+
242
434
  function RecentConversations({
243
435
  conversations,
244
436
  result,
@@ -248,23 +440,32 @@ function RecentConversations({
248
440
  onDelete,
249
441
  }: {
250
442
  conversations: Array<Record<string, unknown>>;
251
- result?: Parameters<typeof SourceBadge>[0]["result"];
443
+ result?: { source?: string; ok?: boolean; status?: number; error?: string };
252
444
  activeId: string | null;
253
445
  onNew: () => void;
254
446
  onSelect: (id: string) => void;
255
447
  onDelete: (id: string) => void;
256
448
  }) {
449
+ const language = useAppStore((state) => state.language);
257
450
  return (
258
451
  <section className="brain-side-panel">
259
452
  <div className="brain-side-head">
260
453
  <div>
261
454
  <h3>Recent conversations</h3>
262
- <SourceBadge result={result} />
455
+ <SourceBadge result={result as Parameters<typeof SourceBadge>[0]["result"]} />
263
456
  </div>
264
457
  <Button variant="outline" size="sm" onClick={onNew}><Plus className="h-4 w-4" /> New</Button>
265
458
  </div>
266
459
  <div className="brain-conversation-list soft-scrollbar">
267
- {conversations.length ? conversations.slice(0, 8).map((item) => {
460
+ {!result?.ok && result?.source === "unavailable" ? (
461
+ <FeedbackState
462
+ tone="error"
463
+ language={language}
464
+ title={t(language, "feedback.error.title")}
465
+ body={result.error || t(language, "feedback.error.body")}
466
+ onAction={() => window.location.reload()}
467
+ />
468
+ ) : conversations.length ? conversations.slice(0, 8).map((item) => {
268
469
  const id = String(item.id || item.conversation_id || "");
269
470
  return (
270
471
  <div key={id} className={`brain-conversation-item ${activeId === id ? "is-active" : ""}`}>
@@ -277,13 +478,23 @@ function RecentConversations({
277
478
  </button>
278
479
  </div>
279
480
  );
280
- }) : <EmptyState title="No conversations yet" detail="New exchanges will appear here." />}
481
+ }) : (
482
+ <FeedbackState
483
+ tone="empty"
484
+ language={language}
485
+ title="No conversations yet"
486
+ body="New exchanges will appear here."
487
+ actionLabel="Start a conversation"
488
+ onAction={onNew}
489
+ />
490
+ )}
281
491
  </div>
282
492
  </section>
283
493
  );
284
494
  }
285
495
 
286
496
  function MemoryNearby({ question, trace }: { question: string; trace: unknown }) {
497
+ const language = useAppStore((state) => state.language);
287
498
  const hybrid = useQuery({
288
499
  queryKey: ["brainNearbyMemory", question],
289
500
  queryFn: () => latticeApi.hybridSearch(question),
@@ -299,10 +510,23 @@ function MemoryNearby({ question, trace }: { question: string; trace: unknown })
299
510
  </div>
300
511
  {hybrid.data?.ok ? (
301
512
  <EntityList items={(hybrid.data.data as Record<string, unknown>).matches || hybrid.data.data} titleKey="title" metaKey="type" limit={4} />
513
+ ) : hybrid.data?.source === "unavailable" ? (
514
+ <FeedbackState
515
+ tone="error"
516
+ language={language}
517
+ title={t(language, "feedback.error.title")}
518
+ body={hybrid.data.error || t(language, "feedback.error.body")}
519
+ onAction={() => void hybrid.refetch()}
520
+ />
302
521
  ) : trace ? (
303
522
  <StructuredView value={trace} limit={4} />
304
523
  ) : (
305
- <EmptyState title="Quiet for now" detail="Relevant memory wakes up as the conversation gets specific." />
524
+ <FeedbackState
525
+ tone="empty"
526
+ language={language}
527
+ title="Quiet for now"
528
+ body="Relevant memory wakes up as the conversation gets specific."
529
+ />
306
530
  )}
307
531
  </section>
308
532
  );
@@ -0,0 +1,45 @@
1
+ import * as React from "react";
2
+ import { AlertTriangle, Inbox, RotateCcw } from "lucide-react";
3
+ import { t, type Language } from "@/i18n";
4
+
5
+ export type FeedbackTone = "empty" | "error";
6
+
7
+ /**
8
+ * Shared empty / error feedback surface so every view tells the user what just
9
+ * happened and what to do next, instead of a blank or silent failure.
10
+ */
11
+ export function FeedbackState({
12
+ tone,
13
+ language,
14
+ title,
15
+ body,
16
+ actionLabel,
17
+ onAction,
18
+ }: {
19
+ tone: FeedbackTone;
20
+ language: Language;
21
+ title: string;
22
+ body?: string;
23
+ actionLabel?: string;
24
+ onAction?: () => void;
25
+ }) {
26
+ const isError = tone === "error";
27
+ const resolvedActionLabel = actionLabel || (isError ? t(language, "feedback.retry") : undefined);
28
+ return (
29
+ <div className={`feedback-state is-${tone}`} role={isError ? "alert" : "status"}>
30
+ <span className="feedback-state-icon" aria-hidden="true">
31
+ {isError ? <AlertTriangle className="h-4 w-4" /> : <Inbox className="h-4 w-4" />}
32
+ </span>
33
+ <div className="feedback-state-body">
34
+ <strong>{title}</strong>
35
+ {body ? <span>{body}</span> : null}
36
+ </div>
37
+ {resolvedActionLabel && onAction ? (
38
+ <button type="button" className="feedback-state-action" onClick={onAction}>
39
+ {isError ? <RotateCcw className="h-3.5 w-3.5" /> : null}
40
+ {resolvedActionLabel}
41
+ </button>
42
+ ) : null}
43
+ </div>
44
+ );
45
+ }
@@ -0,0 +1,176 @@
1
+ import * as React from "react";
2
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { Check, ChevronDown, UserCircle, Building2, ArrowRightLeft } from "lucide-react";
4
+ import { latticeApi } from "@/api/client";
5
+ import { t, type Language } from "@/i18n";
6
+ import { useAppStore } from "@/store/appStore";
7
+ import { asArray } from "@/lib/utils";
8
+
9
+ type WorkspaceRow = Record<string, unknown>;
10
+
11
+ function workspaceId(row: WorkspaceRow): string {
12
+ return String(row.workspace_id || row.id || "");
13
+ }
14
+
15
+ function workspaceName(language: Language, row: WorkspaceRow): string {
16
+ const name = row.name;
17
+ if (typeof name === "string" && name.trim()) return name;
18
+ const id = workspaceId(row);
19
+ return id || t(language, "shell.workspace.personal");
20
+ }
21
+
22
+ function ownerEmail(profile: unknown): string | null {
23
+ if (!profile || typeof profile !== "object") return null;
24
+ const record = profile as Record<string, unknown>;
25
+ const candidate = record.email || record.owner_email || record.username || record.name;
26
+ return typeof candidate === "string" && candidate.trim() ? candidate : null;
27
+ }
28
+
29
+ /**
30
+ * Discoverable workspace + profile switcher for the Brain shell.
31
+ * Surfaces the active workspace and signed-in owner, and lets the user
32
+ * switch workspace or jump to account/workspace settings without hunting
33
+ * through the Settings tabs.
34
+ */
35
+ export function WorkspaceProfileSwitcher({ language }: { language: Language }) {
36
+ const qc = useQueryClient();
37
+ const workspaceIdState = useAppStore((state) => state.workspaceId);
38
+ const setWorkspaceId = useAppStore((state) => state.setWorkspaceId);
39
+ const [open, setOpen] = React.useState(false);
40
+ const containerRef = React.useRef<HTMLDivElement | null>(null);
41
+
42
+ const profile = useQuery({ queryKey: ["profile"], queryFn: latticeApi.profile });
43
+ const registry = useQuery({ queryKey: ["workspaceRegistry"], queryFn: latticeApi.workspaceRegistry });
44
+
45
+ const workspaces = asArray<WorkspaceRow>(
46
+ (registry.data?.data as Record<string, unknown> | undefined)?.workspaces,
47
+ );
48
+ const owner = ownerEmail(profile.data?.data);
49
+
50
+ const activeWorkspace = workspaces.find((row) => workspaceId(row) === workspaceIdState);
51
+ const activeLabel = activeWorkspace
52
+ ? workspaceName(language, activeWorkspace)
53
+ : workspaceIdState || t(language, "shell.workspace.personal");
54
+
55
+ React.useEffect(() => {
56
+ if (!open) return;
57
+ const onPointer = (event: MouseEvent) => {
58
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
59
+ setOpen(false);
60
+ }
61
+ };
62
+ const onKey = (event: KeyboardEvent) => {
63
+ if (event.key === "Escape") setOpen(false);
64
+ };
65
+ window.addEventListener("mousedown", onPointer);
66
+ window.addEventListener("keydown", onKey);
67
+ return () => {
68
+ window.removeEventListener("mousedown", onPointer);
69
+ window.removeEventListener("keydown", onKey);
70
+ };
71
+ }, [open]);
72
+
73
+ const handleSwitch = (id: string) => {
74
+ if (id && id !== workspaceIdState) {
75
+ setWorkspaceId(id);
76
+ // Workspace scoping changes server responses; drop cached views.
77
+ qc.invalidateQueries();
78
+ }
79
+ setOpen(false);
80
+ };
81
+
82
+ return (
83
+ <div className="workspace-profile-switcher" ref={containerRef}>
84
+ <button
85
+ type="button"
86
+ className="workspace-profile-trigger"
87
+ aria-haspopup="dialog"
88
+ aria-expanded={open}
89
+ aria-label={t(language, "shell.workspace.label")}
90
+ onClick={() => setOpen((value) => !value)}
91
+ >
92
+ <Building2 className="h-3.5 w-3.5" aria-hidden="true" />
93
+ <span className="workspace-profile-trigger-text">
94
+ <span className="workspace-profile-trigger-name">{activeLabel}</span>
95
+ <span className="workspace-profile-trigger-owner">{owner || t(language, "shell.profile.signedOut")}</span>
96
+ </span>
97
+ <ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
98
+ </button>
99
+
100
+ {open ? (
101
+ <div className="workspace-profile-popover" role="dialog" aria-label={t(language, "shell.workspace.label")}>
102
+ <section className="workspace-profile-section" aria-label={t(language, "shell.profile.label")}>
103
+ <div className="workspace-profile-section-head">
104
+ <UserCircle className="h-3.5 w-3.5" aria-hidden="true" />
105
+ <span>{t(language, "shell.profile.label")}</span>
106
+ </div>
107
+ <div className="workspace-profile-owner-row">
108
+ <span className="workspace-profile-owner-label">{t(language, "shell.profile.owner")}</span>
109
+ <span className="workspace-profile-owner-value">{owner || t(language, "shell.profile.signedOut")}</span>
110
+ </div>
111
+ <button
112
+ type="button"
113
+ className="workspace-profile-link"
114
+ onClick={() => {
115
+ setOpen(false);
116
+ window.location.hash = "/account";
117
+ }}
118
+ >
119
+ {t(language, "shell.profile.manage")}
120
+ </button>
121
+ </section>
122
+
123
+ <section className="workspace-profile-section" aria-label={t(language, "shell.workspace.current")}>
124
+ <div className="workspace-profile-section-head">
125
+ <Building2 className="h-3.5 w-3.5" aria-hidden="true" />
126
+ <span>{t(language, "shell.workspace.current")}</span>
127
+ </div>
128
+ {workspaces.length === 0 ? (
129
+ <p className="workspace-profile-empty">{t(language, "shell.workspace.empty")}</p>
130
+ ) : (
131
+ <ul className="workspace-profile-list">
132
+ {workspaces.map((row) => {
133
+ const id = workspaceId(row);
134
+ const isActive = id === workspaceIdState;
135
+ return (
136
+ <li key={id}>
137
+ <button
138
+ type="button"
139
+ className={`workspace-profile-item ${isActive ? "is-active" : ""}`}
140
+ aria-current={isActive ? "true" : undefined}
141
+ onClick={() => handleSwitch(id)}
142
+ >
143
+ <span className="workspace-profile-item-name">{workspaceName(language, row)}</span>
144
+ {isActive ? (
145
+ <span className="workspace-profile-item-flag">
146
+ <Check className="h-3 w-3" aria-hidden="true" />
147
+ {t(language, "shell.workspace.active")}
148
+ </span>
149
+ ) : (
150
+ <span className="workspace-profile-item-switch">
151
+ <ArrowRightLeft className="h-3 w-3" aria-hidden="true" />
152
+ {t(language, "shell.workspace.switch")}
153
+ </span>
154
+ )}
155
+ </button>
156
+ </li>
157
+ );
158
+ })}
159
+ </ul>
160
+ )}
161
+ <button
162
+ type="button"
163
+ className="workspace-profile-link"
164
+ onClick={() => {
165
+ setOpen(false);
166
+ window.location.hash = "/workspace-admin";
167
+ }}
168
+ >
169
+ {t(language, "shell.workspace.manageSpaces")}
170
+ </button>
171
+ </section>
172
+ </div>
173
+ ) : null}
174
+ </div>
175
+ );
176
+ }
@@ -23,8 +23,11 @@ export function AnalysisScreen({
23
23
 
24
24
  <div className="ritual-fact-grid">
25
25
  {detected.map((item, idx) => (
26
- <div key={idx} className="ritual-fact">
27
- <div className="ritual-fact-label">{item.label}</div>
26
+ <div key={idx} className="ritual-fact ritual-fact--visual">
27
+ <div className="ritual-fact-head">
28
+ <span className="ritual-hardware-icon" aria-hidden="true">{item.icon}</span>
29
+ <div className="ritual-fact-label">{item.label}</div>
30
+ </div>
28
31
  <div className="ritual-fact-value">{item.value}</div>
29
32
  <div className="ritual-fact-detail">{item.detail}</div>
30
33
  </div>
@@ -57,11 +60,11 @@ export function AnalysisScreen({
57
60
  function buildDetectedFacts(analysis: FlowAnalysis | null, language: Language) {
58
61
  if (!analysis) {
59
62
  return [
60
- { label: t(language, "flow.analysis.fact.computer"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.computerDetail") },
61
- { label: t(language, "flow.analysis.fact.memory"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.memoryDetail") },
62
- { label: t(language, "flow.analysis.fact.graphics"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.graphicsDetail") },
63
- { label: t(language, "flow.analysis.fact.support"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.supportDetail") },
64
- { label: t(language, "flow.analysis.fact.models"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.modelsDetail") },
63
+ { icon: t(language, "flow.analysis.hardware.icon.chip"), label: t(language, "flow.analysis.hardware.label.chip"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.computerDetail") },
64
+ { icon: t(language, "flow.analysis.hardware.icon.ram"), label: t(language, "flow.analysis.hardware.label.ram"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.memoryDetail") },
65
+ { icon: t(language, "flow.analysis.hardware.icon.gpu"), label: t(language, "flow.analysis.hardware.label.gpu"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.graphicsDetail") },
66
+ { icon: t(language, "flow.analysis.hardware.icon.support"), label: t(language, "flow.analysis.hardware.label.support"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.supportDetail") },
67
+ { icon: t(language, "flow.analysis.hardware.icon.models"), label: t(language, "flow.analysis.hardware.label.models"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.modelsDetail") },
65
68
  ];
66
69
  }
67
70
  const setupEnv = asRecord(analysis.setup?.environment);
@@ -81,27 +84,32 @@ function buildDetectedFacts(analysis: FlowAnalysis | null, language: Language) {
81
84
  ];
82
85
  return [
83
86
  {
84
- label: t(language, "flow.analysis.fact.computer"),
87
+ icon: t(language, "flow.analysis.hardware.icon.chip"),
88
+ label: t(language, "flow.analysis.hardware.label.chip"),
85
89
  value: appleSilicon ? t(language, "flow.analysis.apple") : friendlyOs(profile.os, language),
86
90
  detail: t(language, "flow.analysis.readyDetail"),
87
91
  },
88
92
  {
89
- label: t(language, "flow.analysis.fact.memory"),
93
+ icon: t(language, "flow.analysis.hardware.icon.ram"),
94
+ label: t(language, "flow.analysis.hardware.label.ram"),
90
95
  value: ramGb ? `${Math.round(ramGb)} GB` : t(language, "flow.analysis.detected"),
91
96
  detail: t(language, "flow.analysis.memoryReadyDetail"),
92
97
  },
93
98
  {
94
- label: t(language, "flow.analysis.fact.graphics"),
99
+ icon: t(language, "flow.analysis.hardware.icon.gpu"),
100
+ label: t(language, "flow.analysis.hardware.label.gpu"),
95
101
  value: gpu.vendor || sysinfo.gpu_mem_gb ? t(language, "flow.analysis.localReady") : t(language, "flow.analysis.standardLocal"),
96
102
  detail: t(language, "flow.analysis.graphicsReadyDetail"),
97
103
  },
98
104
  {
99
- label: t(language, "flow.analysis.fact.support"),
105
+ icon: t(language, "flow.analysis.hardware.icon.support"),
106
+ label: t(language, "flow.analysis.hardware.label.support"),
100
107
  value: installedRuntimes.length ? t(language, "flow.analysis.supportReady") : t(language, "flow.analysis.supportInstall"),
101
108
  detail: installedRuntimes.length ? t(language, "flow.analysis.supportReadyDetail") : t(language, "flow.analysis.supportInstallDetail"),
102
109
  },
103
110
  {
104
- label: t(language, "flow.analysis.fact.models"),
111
+ icon: t(language, "flow.analysis.hardware.icon.models"),
112
+ label: t(language, "flow.analysis.hardware.label.models"),
105
113
  value: loadedModels.length ? t(language, "flow.analysis.modelsInstalled", { count: loadedModels.length }) : t(language, "flow.analysis.noModels"),
106
114
  detail: loadedModels.length ? t(language, "flow.analysis.modelsReadyDetail") : t(language, "flow.analysis.modelsInstallDetail"),
107
115
  },