ltcai 7.0.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 (61) hide show
  1. package/README.md +20 -20
  2. package/docs/CHANGELOG.md +23 -0
  3. package/frontend/src/App.tsx +80 -0
  4. package/frontend/src/api/client.ts +6 -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 +232 -19
  14. package/frontend/src/features/brain/BrainGraphLayer.tsx +258 -25
  15. package/frontend/src/features/brain/BrainHome.tsx +193 -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 +41 -0
  19. package/frontend/src/i18n.ts +304 -0
  20. package/frontend/src/store/appStore.ts +15 -0
  21. package/frontend/src/styles.css +1130 -0
  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/src-tauri/Cargo.lock +1 -1
  30. package/src-tauri/Cargo.toml +1 -1
  31. package/src-tauri/tauri.conf.json +1 -1
  32. package/static/app/asset-manifest.json +28 -28
  33. package/static/app/assets/Act-BHEb8bAM.js +2 -0
  34. package/static/app/assets/{Act-DhLaHnrT.js.map → Act-BHEb8bAM.js.map} +1 -1
  35. package/static/app/assets/Brain-yvR7xkrV.js +322 -0
  36. package/static/app/assets/Brain-yvR7xkrV.js.map +1 -0
  37. package/static/app/assets/{Capture-CAKouj52.js → Capture-BrAmsSEH.js} +2 -2
  38. package/static/app/assets/{Capture-CAKouj52.js.map → Capture-BrAmsSEH.js.map} +1 -1
  39. package/static/app/assets/Library-BOVzYfxI.js +2 -0
  40. package/static/app/assets/{Library-B_hW4AwR.js.map → Library-BOVzYfxI.js.map} +1 -1
  41. package/static/app/assets/System-D4WaN4kj.js +2 -0
  42. package/static/app/assets/System-D4WaN4kj.js.map +1 -0
  43. package/static/app/assets/index-BOB-W1FZ.js +17 -0
  44. package/static/app/assets/index-BOB-W1FZ.js.map +1 -0
  45. package/static/app/assets/{index-DKFpn5Kn.css → index-DvLsGy-z.css} +1 -1
  46. package/static/app/assets/primitives-C3_BfUb8.js +2 -0
  47. package/static/app/assets/primitives-C3_BfUb8.js.map +1 -0
  48. package/static/app/assets/textarea-CbvhHvzg.js +2 -0
  49. package/static/app/assets/{textarea-DR2Tyy_6.js.map → textarea-CbvhHvzg.js.map} +1 -1
  50. package/static/app/index.html +2 -2
  51. package/static/app/assets/Act-DhLaHnrT.js +0 -2
  52. package/static/app/assets/Brain-DS7NULyY.js +0 -322
  53. package/static/app/assets/Brain-DS7NULyY.js.map +0 -1
  54. package/static/app/assets/Library-B_hW4AwR.js +0 -2
  55. package/static/app/assets/System-9OAoXWPz.js +0 -2
  56. package/static/app/assets/System-9OAoXWPz.js.map +0 -1
  57. package/static/app/assets/index-C-7aHabu.js +0 -17
  58. package/static/app/assets/index-C-7aHabu.js.map +0 -1
  59. package/static/app/assets/primitives-DcO2H3yh.js +0 -2
  60. package/static/app/assets/primitives-DcO2H3yh.js.map +0 -1
  61. package/static/app/assets/textarea-DR2Tyy_6.js +0 -2
package/README.md CHANGED
@@ -200,26 +200,25 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for developer workflow details.
200
200
 
201
201
  ## Current Release
202
202
 
203
- The current release is **7.0.0 Brain Productization Loop**:
204
-
205
- - Brain Home starts with ingestion: files, folders, notes, and web pages can be
206
- added directly from the first screen.
207
- - Every Brain answer can show memory proof and source citations instead of
208
- asking users to trust an ungrounded chat bubble.
209
- - The model-continuity demo lets users recheck the same Brain evidence after
210
- changing models.
211
- - The first-run loop is optimized for five minutes: add source, ask, see proof,
212
- then switch or deepen.
213
- - CI now includes a deterministic recall/KG quality eval so the Brain proof
214
- promise is guarded before release.
215
-
216
- Expected artifacts for 7.0.0 release must use exact filenames:
217
-
218
- - `dist/ltcai-7.0.0-py3-none-any.whl`
219
- - `dist/ltcai-7.0.0.tar.gz`
220
- - `ltcai-7.0.0.tgz`
221
- - `dist/ltcai-7.0.0.vsix`
222
- - `src-tauri/target/release/bundle/dmg/Lattice AI_7.0.0_aarch64.dmg`
203
+ The current release is **7.1.0 Brain Usability Completion**:
204
+
205
+ - First-run onboarding now explains detected hardware, recommendation timing,
206
+ install progress, and the next action in plain language.
207
+ - Brain Home shows ingestion progress for files, folders, notes, and web pages,
208
+ including memory emergence after content lands.
209
+ - Knowledge Graph exploration adds search suggestions, type filters, time
210
+ exploration, focus movement, and neighbor highlighting.
211
+ - Chat answers surface inline source citations and accessible proof payloads.
212
+ - Workspace/profile/admin entry points, empty/error feedback, consent revoke
213
+ feedback, and VS Code extension sync status are visible in the product shell.
214
+
215
+ Expected artifacts for 7.1.0 release must use exact filenames:
216
+
217
+ - `dist/ltcai-7.1.0-py3-none-any.whl`
218
+ - `dist/ltcai-7.1.0.tar.gz`
219
+ - `ltcai-7.1.0.tgz`
220
+ - `dist/ltcai-7.1.0.vsix`
221
+ - `src-tauri/target/release/bundle/dmg/Lattice AI_7.1.0_aarch64.dmg`
223
222
 
224
223
  Do not upload `dist/*`. Package registry publishing remains owner-run.
225
224
 
@@ -238,6 +237,7 @@ Do not upload `dist/*`. Package registry publishing remains owner-run.
238
237
 
239
238
  | Version | Theme |
240
239
  | --- | --- |
240
+ | 7.1.0 | Brain Usability Completion: clearer first-run onboarding, ingestion progress/emergence, richer graph controls, inline answer proof, workspace/profile/admin discovery, empty/error/consent feedback, and VS Code sync status |
241
241
  | 7.0.0 | Brain Productization Loop: first-screen ingestion for files/folders/notes/web, answer-level memory proof and source citations, model-continuity demo flow, five-minute first-run loop, and recall/KG quality eval in CI |
242
242
  | 6.7.0 | Brain IA Cleanup: reachable rich pages, separated product routes vs compatibility aliases, shared Brain shell navigation, and lazy-loaded rich pages |
243
243
  | 6.6.0 | Brain Proof Runtime: backend-owned Brain proof API, model-continuity wiring, first-screen proof that saved context can be recalled across model changes, and direct Brain Home document upload |
package/docs/CHANGELOG.md CHANGED
@@ -4,6 +4,29 @@ The top entry is either the current unreleased main-branch work or the current
4
4
  release line. Older entries are historical and may describe behavior as it
5
5
  existed at that release.
6
6
 
7
+ ## [7.1.0] - 2026-06-20
8
+
9
+ > Brain Usability Completion. Completes the 7.1.0 first-run through editor-sync
10
+ > usability pass: clear onboarding, visible ingestion, graph controls, inline
11
+ > proof, workspace/admin discovery, feedback states, and VS Code sync status.
12
+
13
+ ### Added
14
+ - Hardware visualization, expected timing, install timeline, and next-action
15
+ copy in first-run onboarding.
16
+ - Brain Home ingestion stage disclosure and memory emergence timeline for file,
17
+ folder, note, and URL sources.
18
+ - Knowledge Graph search suggestions, entity type filters, recent/all-time time
19
+ exploration, focus clearing, and neighbor highlighting.
20
+ - Inline answer citation markers with keyboard-accessible proof cards.
21
+ - Workspace/profile switcher, Admin Console gate, consent revoke feedback, and
22
+ shared empty/error feedback surfaces in the Brain shell.
23
+ - VS Code extension heartbeat/status endpoint plus extension and main-app sync
24
+ indicators for connected/indexing/synced/offline states.
25
+
26
+ ### Changed
27
+ - Package/runtime/static metadata is synchronized to 7.1.0; package publish and
28
+ deployment remain owner-run only.
29
+
7
30
  ## [7.0.0] - 2026-06-18
8
31
 
9
32
  > Brain Productization Loop. Turns the Brain proof work into a first-five-minute
@@ -5,6 +5,12 @@ import { useAppStore } from "@/store/appStore";
5
5
  import { parseHash, productShellRoutes } from "@/routes";
6
6
  import { BrainHome } from "@/features/brain/BrainHome";
7
7
  import { AdminConsole } from "@/features/admin/AdminConsole";
8
+ import { WorkspaceProfileSwitcher } from "@/components/WorkspaceProfileSwitcher";
9
+ import { AdminAccessGate } from "@/components/AdminAccessGate";
10
+ import { t, type Language } from "@/i18n";
11
+ import { latticeApi } from "@/api/client";
12
+ import { useQuery } from "@tanstack/react-query";
13
+ import { FeedbackState } from "@/components/FeedbackState";
8
14
 
9
15
  const ActPage = React.lazy(() => import("@/pages/Act").then((module) => ({ default: module.ActPage })));
10
16
  const BrainPage = React.lazy(() => import("@/pages/Brain").then((module) => ({ default: module.BrainPage })));
@@ -95,6 +101,7 @@ function BrainShell({
95
101
  active: string;
96
102
  children: React.ReactNode;
97
103
  }) {
104
+ const language = useAppStore((state) => state.language);
98
105
  return (
99
106
  <main className="brain-shell-page" aria-label="Lattice workspace">
100
107
  <nav className="brain-shell-nav" aria-label="Brain workspace navigation">
@@ -103,19 +110,92 @@ function BrainShell({
103
110
  key={item.id}
104
111
  type="button"
105
112
  className={item.id === active ? "is-active" : ""}
113
+ aria-current={item.id === active ? "page" : undefined}
106
114
  onClick={() => navigateHash(`/${item.path}`)}
107
115
  >
108
116
  {item.label}
109
117
  </button>
110
118
  ))}
119
+ <div className="brain-shell-switchers" aria-label={t(language, "shell.workspace.label")}>
120
+ <VsCodeSyncStatus language={language} />
121
+ <WorkspaceProfileSwitcher language={language} />
122
+ <AdminAccessGate language={language} />
123
+ </div>
111
124
  </nav>
112
125
  <section className="brain-shell-content">
126
+ <ExternalConsentStatus language={language} />
113
127
  {children}
114
128
  </section>
115
129
  </main>
116
130
  );
117
131
  }
118
132
 
133
+ function ExternalConsentStatus({ language }: { language: Language }) {
134
+ const externalConsent = useAppStore((state) => state.externalConsent);
135
+ const setExternalConsent = useAppStore((state) => state.setExternalConsent);
136
+
137
+ return (
138
+ <section className="external-consent-status" aria-label={t(language, "feedback.consent.aria")}>
139
+ <FeedbackState
140
+ tone={externalConsent ? "empty" : "error"}
141
+ language={language}
142
+ title={externalConsent ? t(language, "feedback.consent.activeTitle") : t(language, "feedback.consent.revokedTitle")}
143
+ body={externalConsent ? t(language, "feedback.consent.activeBody") : t(language, "feedback.consent.revokedBody")}
144
+ actionLabel={externalConsent ? t(language, "feedback.consent.revoke") : t(language, "feedback.consent.reenable")}
145
+ onAction={() => setExternalConsent(!externalConsent)}
146
+ />
147
+ </section>
148
+ );
149
+ }
150
+
151
+ function VsCodeSyncStatus({ language }: { language: Language }) {
152
+ const bridge = useQuery({
153
+ queryKey: ["workspaceVscodeStatus"],
154
+ queryFn: latticeApi.workspaceVscodeStatus,
155
+ refetchInterval: 15000,
156
+ });
157
+ const index = useQuery({
158
+ queryKey: ["index"],
159
+ queryFn: latticeApi.indexStatus,
160
+ refetchInterval: 15000,
161
+ });
162
+ const data = bridge.data?.data as Record<string, unknown> | undefined;
163
+ const lastSeen = Number(data?.last_seen_ms || 0);
164
+ const connected = Boolean(data?.connected) || (lastSeen > 0 && Date.now() - lastSeen < 60000);
165
+ const indexData = index.data?.data as Record<string, unknown> | undefined;
166
+ const indexStatus = String(indexData?.status || indexData?.state || "");
167
+ const indexing = /index|build|running|pending/i.test(indexStatus);
168
+ const state = bridge.isLoading
169
+ ? "checking"
170
+ : !connected
171
+ ? "offline"
172
+ : indexing
173
+ ? "indexing"
174
+ : "synced";
175
+ const labelKey = `shell.sync.${state}`;
176
+ const detail = connected
177
+ ? t(language, "shell.sync.detail")
178
+ : t(language, "shell.sync.detailOffline");
179
+
180
+ return (
181
+ <button
182
+ type="button"
183
+ className={`vscode-sync-status is-${state}`}
184
+ aria-label={`${t(language, "shell.sync.label")}: ${t(language, labelKey)}`}
185
+ title={`${t(language, "shell.sync.label")}: ${t(language, labelKey)}\n${detail}`}
186
+ onClick={() => {
187
+ window.location.hash = "/settings";
188
+ }}
189
+ >
190
+ <span className="vscode-sync-dot" aria-hidden="true" />
191
+ <span className="vscode-sync-copy">
192
+ <strong>{t(language, "shell.sync.label")}</strong>
193
+ <small>{t(language, labelKey)}</small>
194
+ </span>
195
+ </button>
196
+ );
197
+ }
198
+
119
199
  function PageLoader() {
120
200
  return (
121
201
  <div className="brain-shell-loader" role="status">
@@ -416,6 +416,12 @@ export const latticeApi = {
416
416
  },
417
417
  health: () => get("/health", {}),
418
418
  workspaceOs: () => get("/workspace/os", { counts: {}, models: {}, workspace_registry: { workspaces: [] } }),
419
+ workspaceVscodeStatus: () => get("/workspace/vscode/status", {
420
+ connected: false,
421
+ last_seen_ms: 0,
422
+ status: "offline",
423
+ index_status: "unknown",
424
+ }),
419
425
  indexStatus: () => get("/api/index/status", {}),
420
426
  rebuildIndex: () => post("/api/index/rebuild", { full: false, include_nodes: true, include_chunks: true }, {}),
421
427
  graph: () => get("/knowledge-graph/graph", { nodes: [], edges: [] }),
@@ -0,0 +1,70 @@
1
+ import * as React from "react";
2
+ import { ShieldCheck, Lock } from "lucide-react";
3
+ import { t, type Language } from "@/i18n";
4
+ import { useAppStore, type WorkspaceMode } from "@/store/appStore";
5
+
6
+ const MODES: WorkspaceMode[] = ["basic", "advanced", "admin"];
7
+
8
+ /**
9
+ * Role-aware admin entry point for the Brain shell.
10
+ *
11
+ * Mode is the access proxy used across the app (SystemPage gates the admin
12
+ * tab and AdminPanel on `mode`). When the user is in advanced/admin mode we
13
+ * surface a discoverable Admin Console button plus a compact mode switcher.
14
+ * In basic mode the console is hidden behind an explained affordance that
15
+ * promotes the user to admin mode rather than failing silently.
16
+ */
17
+ export function AdminAccessGate({ language }: { language: Language }) {
18
+ const mode = useAppStore((state) => state.mode);
19
+ const setMode = useAppStore((state) => state.setMode);
20
+ const canSeeAdmin = mode === "advanced" || mode === "admin";
21
+
22
+ return (
23
+ <div className="admin-access-gate">
24
+ {canSeeAdmin ? (
25
+ <button
26
+ type="button"
27
+ className="admin-access-button"
28
+ title={t(language, "shell.admin.tooltip")}
29
+ aria-label={t(language, "shell.admin.open")}
30
+ onClick={() => {
31
+ window.location.hash = "/admin";
32
+ }}
33
+ >
34
+ <ShieldCheck className="h-3.5 w-3.5" aria-hidden="true" />
35
+ <span>{t(language, "shell.admin.label")}</span>
36
+ </button>
37
+ ) : (
38
+ <button
39
+ type="button"
40
+ className="admin-access-button is-locked"
41
+ title={t(language, "shell.admin.needsMode")}
42
+ aria-label={t(language, "shell.admin.needsMode")}
43
+ onClick={() => setMode("admin")}
44
+ >
45
+ <Lock className="h-3.5 w-3.5" aria-hidden="true" />
46
+ <span>{t(language, "shell.admin.enable")}</span>
47
+ </button>
48
+ )}
49
+
50
+ <div
51
+ className="mode-switcher-mini"
52
+ role="group"
53
+ aria-label={t(language, "shell.mode.label")}
54
+ title={t(language, "shell.mode.info")}
55
+ >
56
+ {MODES.map((item) => (
57
+ <button
58
+ key={item}
59
+ type="button"
60
+ className={mode === item ? "is-active" : ""}
61
+ aria-pressed={mode === item}
62
+ onClick={() => setMode(item)}
63
+ >
64
+ {t(language, `shell.mode.${item}`)}
65
+ </button>
66
+ ))}
67
+ </div>
68
+ </div>
69
+ );
70
+ }
@@ -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
+ }