ltcai 5.6.0 → 6.0.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 (40) hide show
  1. package/README.md +42 -25
  2. package/docs/CHANGELOG.md +38 -0
  3. package/frontend/openapi.json +39 -0
  4. package/frontend/src/api/client.ts +104 -23
  5. package/frontend/src/api/openapi.ts +48 -0
  6. package/frontend/src/components/FirstRunGuide.tsx +3 -3
  7. package/frontend/src/features/review/ReviewCard.tsx +91 -0
  8. package/frontend/src/features/review/ReviewInbox.tsx +112 -0
  9. package/frontend/src/features/review/reviewHelpers.ts +69 -0
  10. package/frontend/src/i18n.ts +8 -8
  11. package/frontend/src/pages/Act.tsx +5 -177
  12. package/frontend/src/routes.ts +1 -0
  13. package/lattice_brain/__init__.py +1 -1
  14. package/lattice_brain/runtime/multi_agent.py +1 -1
  15. package/latticeai/__init__.py +1 -1
  16. package/latticeai/api/review_queue.py +7 -3
  17. package/latticeai/app_factory.py +224 -473
  18. package/latticeai/core/marketplace.py +1 -1
  19. package/latticeai/core/workspace_os.py +1 -1
  20. package/latticeai/runtime/app_context_runtime.py +13 -0
  21. package/latticeai/runtime/automation_runtime.py +64 -0
  22. package/latticeai/runtime/bootstrap.py +48 -0
  23. package/latticeai/runtime/context_runtime.py +43 -0
  24. package/latticeai/runtime/hooks_runtime.py +77 -0
  25. package/latticeai/runtime/lifespan_runtime.py +138 -0
  26. package/latticeai/runtime/persistence_runtime.py +87 -0
  27. package/latticeai/runtime/platform_services_runtime.py +39 -0
  28. package/latticeai/runtime/router_registration.py +570 -0
  29. package/latticeai/runtime/web_runtime.py +65 -0
  30. package/latticeai/services/review_queue.py +20 -4
  31. package/package.json +1 -1
  32. package/src-tauri/Cargo.lock +1 -1
  33. package/src-tauri/Cargo.toml +1 -1
  34. package/src-tauri/tauri.conf.json +1 -1
  35. package/static/app/asset-manifest.json +3 -3
  36. package/static/app/assets/index-D2zafMYb.js +16 -0
  37. package/static/app/assets/index-D2zafMYb.js.map +1 -0
  38. package/static/app/index.html +1 -1
  39. package/static/app/assets/index-xMFu94cX.js +0 -16
  40. package/static/app/assets/index-xMFu94cX.js.map +0 -1
package/README.md CHANGED
@@ -69,35 +69,42 @@ You need Lattice AI when:
69
69
  Choose the owner of the Brain. The profile is not a SaaS account by default; it
70
70
  is the local identity for the knowledge you keep.
71
71
 
72
- ![Login](output/release/v5.3.0/screenshots/01-login.png)
72
+ ![Login](output/release/v6.0.0/screenshots/01-login.png)
73
73
 
74
74
  ### 2. Environment Analysis
75
75
 
76
76
  See what kind of local AI experience this computer can support before choosing a
77
77
  model.
78
78
 
79
- ![Environment Analysis](output/release/v5.3.0/screenshots/02-environment-analysis.png)
79
+ ![Environment Analysis](output/release/v6.0.0/screenshots/02-environment-analysis.png)
80
80
 
81
81
  ### 3. Recommended Models
82
82
 
83
83
  Start with a short list: safest recommendation, faster model, stronger model.
84
84
  Advanced details stay available without overwhelming first-time users.
85
85
 
86
- ![Recommended Models](output/release/v5.3.0/screenshots/03-recommended-models.png)
86
+ ![Recommended Models](output/release/v6.0.0/screenshots/03-recommended-models.png)
87
87
 
88
88
  ### 4. Install And Load
89
89
 
90
90
  Download and load only after consent. Lattice explains model size, local
91
91
  execution, and network use before work starts.
92
92
 
93
- ![Install and Load](output/release/v5.3.0/screenshots/04-install-load-progress.png)
93
+ ![Install and Load](output/release/v6.0.0/screenshots/04-install-load-progress.png)
94
94
 
95
95
  ### 5. Brain Chat
96
96
 
97
97
  Talk normally. Useful decisions and context become memory, then appear later as
98
98
  topics, relationships, and graph structure.
99
99
 
100
- ![Brain Chat Home](output/release/v5.3.0/screenshots/05-brain-chat-home.png)
100
+ ![Brain Chat Home](output/release/v6.0.0/screenshots/05-brain-chat-home.png)
101
+
102
+ ### 6. Review Center
103
+
104
+ Automation results are staged for review before they become durable decisions.
105
+ Snooze, unsnooze, run now, approve, and dismiss actions stay explicit.
106
+
107
+ ![Review Center](output/release/v6.0.0/screenshots/13-review-center.png)
101
108
 
102
109
  ## Brain Depths
103
110
 
@@ -113,10 +120,10 @@ The user travels inward from everyday memory to deeper structure:
113
120
 
114
121
  Walkthrough:
115
122
 
116
- ![v5.3.0 Living Brain walkthrough](output/release/v5.3.0/gifs/v5.3.0-living-brain-walkthrough.gif)
123
+ ![v6.0.0 Living Brain walkthrough](output/release/v6.0.0/gifs/v6.0.0-living-brain-walkthrough.gif)
117
124
 
118
125
  Screenshot index and capture notes:
119
- [output/release/v5.3.0/SCREENSHOT_INDEX.md](output/release/v5.3.0/SCREENSHOT_INDEX.md)
126
+ [output/release/v6.0.0/SCREENSHOT_INDEX.md](output/release/v6.0.0/SCREENSHOT_INDEX.md)
120
127
 
121
128
  ## Install
122
129
 
@@ -193,24 +200,33 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for developer workflow details.
193
200
 
194
201
  ## Current Release Preparation
195
202
 
196
- The current development target is **5.6.0 Brain Automation Review Center**:
197
-
198
- - Automation output now lands in a workspace-scoped Review inbox before users
199
- approve, dismiss, snooze, or rerun suggestions.
200
- - `/automation/reviews` exposes source-aware review items with provenance,
201
- `effective_status`, and guarded actions.
202
- - TriggerService and RunExecutor can enqueue review items only when workflows
203
- explicitly opt in with `review_queue: true`.
204
- - Act now includes a Review tab under Runs for pending automation suggestions.
205
- - All package/runtime/static/OpenAPI versions are synchronized to 5.6.0.
206
-
207
- Expected artifacts for 5.6.0 release must use exact filenames:
208
-
209
- - `dist/ltcai-5.6.0-py3-none-any.whl`
210
- - `dist/ltcai-5.6.0.tar.gz`
211
- - `ltcai-5.6.0.tgz`
212
- - `dist/ltcai-5.6.0.vsix`
213
- - `src-tauri/target/release/bundle/dmg/Lattice AI_5.6.0_aarch64.dmg`
203
+ The current development target is **6.0.0 Product Reset / Review Center Completion**:
204
+
205
+ - Review Center now includes Pending, Snoozed, and All filters so users can find
206
+ deferred automation suggestions without waiting for expiry.
207
+ - `/automation/reviews` adds explicit `unsnooze` support while preserving
208
+ read-time snooze expiry and `run_now != approve` semantics.
209
+ - Review Center frontend code is split into `frontend/src/features/review/`
210
+ modules instead of living inside the Act page.
211
+ - Review item frontend types are derived from regenerated OpenAPI component
212
+ - schemas, and Review Center API calls use generated operation paths for list
213
+ and state-transition actions.
214
+ - `app_factory.py` is decomposed behind runtime seams for session, hooks, web
215
+ shell, persistence, lifespan, automation, context/search, platform services,
216
+ app context, and router registration while preserving the frozen 364-entry
217
+ route/mount snapshot.
218
+ - First-run copy now states the local-first trust boundary more directly:
219
+ knowledge stays on this computer by default, downloads/external transfer start
220
+ only after user action, and models remain replaceable.
221
+ - All package/runtime/static/OpenAPI versions are synchronized to 6.0.0.
222
+
223
+ Expected artifacts for 6.0.0 release must use exact filenames:
224
+
225
+ - `dist/ltcai-6.0.0-py3-none-any.whl`
226
+ - `dist/ltcai-6.0.0.tar.gz`
227
+ - `ltcai-6.0.0.tgz`
228
+ - `dist/ltcai-6.0.0.vsix`
229
+ - `src-tauri/target/release/bundle/dmg/Lattice AI_6.0.0_aarch64.dmg`
214
230
 
215
231
  Do not upload `dist/*`. Package registry publishing remains owner-run.
216
232
 
@@ -229,6 +245,7 @@ Do not upload `dist/*`. Package registry publishing remains owner-run.
229
245
 
230
246
  | Version | Theme |
231
247
  | --- | --- |
248
+ | 6.0.0 | Product Reset / Review Center Completion: Snoozed filter, Unsnooze, OpenAPI-derived Review typing, Review feature extraction, v6 docs and scorecard |
232
249
  | 5.6.0 | Brain Automation Review Center: workspace-scoped automation review inbox, source-aware provenance, guarded approve/dismiss/snooze/run_now actions, and Act Review tab |
233
250
  | 5.5.0 | Release Coordination: synchronized package/runtime/static versions and release docs for the 5.5.0 line while preserving v5.4.0 Brain Automation Scheduler behavior |
234
251
  | 5.4.0 | Brain Automation Scheduler: consent-first recipe drafts (Daily/Weekly/Follow-up), TriggerService with dedup/LATTICE_TZ/degraded, runtime graph cleanup, E2E scenarios |
package/docs/CHANGELOG.md CHANGED
@@ -3,6 +3,44 @@
3
3
  The top entry is the current release-preparation target. Older entries are
4
4
  historical and may describe behavior as it existed at that release.
5
5
 
6
+ ## [6.0.0] - 2026-06-15
7
+
8
+ > Product Reset / Review Center Completion. Raises the Review Center from a
9
+ > pending-only inbox into a reversible automation review surface while
10
+ > documenting the v6 quality uplift honestly.
11
+
12
+ ### Added
13
+ - Review Center status filters for Pending, Snoozed, and All.
14
+ - Explicit `POST /automation/reviews/{item_id}/unsnooze` API and backend policy.
15
+ - Frontend Unsnooze action and clear `snoozed_until` presentation.
16
+ - `docs/v6/PLAN.md`, `ARCHITECTURE_REVIEW.md`, `UX_REVIEW.md`, and
17
+ `QUALITY_SCORECARD.md`.
18
+
19
+ ### Changed
20
+ - Review Center frontend moved from `Act.tsx` into
21
+ `frontend/src/features/review/` components and helpers.
22
+ - Review item frontend types now alias generated OpenAPI component schemas.
23
+ - Review Center API calls now use generated OpenAPI operation paths for list,
24
+ approve, dismiss, snooze, unsnooze, and run_now actions.
25
+ - `app_factory.py` runtime assembly moved behind session, hooks, web,
26
+ persistence, lifespan, automation, context/search, platform service, app
27
+ context, and router-registration seams while preserving the frozen 364-entry
28
+ route/mount snapshot.
29
+ - First-run/onboarding copy now states local-first trust boundaries more
30
+ directly: local knowledge by default, explicit downloads, and explicit
31
+ external transfer.
32
+ - README release evidence screenshots and walkthrough GIF are refreshed under
33
+ `output/release/v6.0.0/`, including the Review Center surface.
34
+ - OpenAPI artifacts and synchronized package/runtime/static metadata now target
35
+ `6.0.0`.
36
+
37
+ ### Preserved
38
+ - `run_now` remains preview/regenerate and does not approve.
39
+ - Snooze expiry remains read-time only; explicit unsnooze is the only immediate
40
+ return-to-pending mutation.
41
+ - Package publishing, GitHub Release creation, artifact upload, and merge to
42
+ `main` remain out of scope for this branch.
43
+
6
44
  ## [5.6.0] - 2026-06-15
7
45
 
8
46
  > Brain Automation Review Center. Adds a workspace-scoped review inbox for
@@ -7867,6 +7867,45 @@
7867
7867
  "summary": "Snooze Item"
7868
7868
  }
7869
7869
  },
7870
+ "/automation/reviews/{item_id}/unsnooze": {
7871
+ "post": {
7872
+ "operationId": "unsnooze_item_automation_reviews__item_id__unsnooze_post",
7873
+ "parameters": [
7874
+ {
7875
+ "in": "path",
7876
+ "name": "item_id",
7877
+ "required": true,
7878
+ "schema": {
7879
+ "title": "Item Id",
7880
+ "type": "string"
7881
+ }
7882
+ }
7883
+ ],
7884
+ "responses": {
7885
+ "200": {
7886
+ "content": {
7887
+ "application/json": {
7888
+ "schema": {
7889
+ "$ref": "#/components/schemas/ReviewItem"
7890
+ }
7891
+ }
7892
+ },
7893
+ "description": "Successful Response"
7894
+ },
7895
+ "422": {
7896
+ "content": {
7897
+ "application/json": {
7898
+ "schema": {
7899
+ "$ref": "#/components/schemas/HTTPValidationError"
7900
+ }
7901
+ }
7902
+ },
7903
+ "description": "Validation Error"
7904
+ }
7905
+ },
7906
+ "summary": "Unsnooze Item"
7907
+ }
7908
+ },
7870
7909
  "/chat": {
7871
7910
  "get": {
7872
7911
  "operationId": "chat_page_chat_get",
@@ -1,5 +1,5 @@
1
1
  import createClient from "openapi-fetch";
2
- import type { paths } from "./openapi";
2
+ import type { components, operations, paths } from "./openapi";
3
3
  import { useAppStore } from "@/store/appStore";
4
4
 
5
5
  export type ApiResult<T = unknown> = {
@@ -21,7 +21,19 @@ export type AdminAuditFilters = {
21
21
  };
22
22
 
23
23
  const TIMEOUT_MS = 10_000;
24
- const clients = new Map<string, ReturnType<typeof createClient<paths>>>();
24
+ type OpenApiClient = ReturnType<typeof createClient<paths>>;
25
+ type OperationJson200<Operation> = Operation extends {
26
+ responses: { 200: { content: { "application/json": infer Result } } };
27
+ } ? Result : never;
28
+ type ReviewListOperation = operations["list_items_automation_reviews_get"];
29
+ type ReviewItemOperation =
30
+ | operations["approve_item_automation_reviews__item_id__approve_post"]
31
+ | operations["dismiss_item_automation_reviews__item_id__dismiss_post"]
32
+ | operations["run_now_item_automation_reviews__item_id__run_now_post"]
33
+ | operations["snooze_item_automation_reviews__item_id__snooze_post"]
34
+ | operations["unsnooze_item_automation_reviews__item_id__unsnooze_post"];
35
+
36
+ const clients = new Map<string, OpenApiClient>();
25
37
  let desktopBase: Promise<string | null> | null = null;
26
38
 
27
39
  declare global {
@@ -145,6 +157,39 @@ async function apiJson<T>(
145
157
  }
146
158
  }
147
159
 
160
+ async function openApiJson<T>(
161
+ shape: T,
162
+ execute: (client: OpenApiClient, signal: AbortSignal) => Promise<{ data?: T; error?: unknown; response: Response }>,
163
+ ): Promise<ApiResult<T>> {
164
+ const base = await apiBase();
165
+ const client = clientFor(base);
166
+ const ctrl = new AbortController();
167
+ const timer = window.setTimeout(() => ctrl.abort(), TIMEOUT_MS);
168
+ try {
169
+ const { data, error, response } = await execute(client, ctrl.signal);
170
+ if (response.ok && data !== undefined) {
171
+ return { ok: true, status: response.status, data, source: "live" };
172
+ }
173
+ return {
174
+ ok: false,
175
+ status: response.status,
176
+ data: emptyFor(shape),
177
+ source: "unavailable",
178
+ error: friendlyError(error, response.statusText),
179
+ };
180
+ } catch (err) {
181
+ return {
182
+ ok: false,
183
+ status: 0,
184
+ data: emptyFor(shape),
185
+ source: "unavailable",
186
+ error: err instanceof Error ? err.message : String(err),
187
+ };
188
+ } finally {
189
+ window.clearTimeout(timer);
190
+ }
191
+ }
192
+
148
193
  function get<T>(path: string, shape: T, query?: Query) {
149
194
  return apiJson<T>("GET", path, { query, shape });
150
195
  }
@@ -161,20 +206,10 @@ function del<T>(path: string, shape: T) {
161
206
  return apiJson<T>("DELETE", path, { shape });
162
207
  }
163
208
 
164
- export type ReviewItem = {
165
- id: string;
166
- status: string;
167
- effective_status: string;
168
- title: string;
169
- summary?: string;
170
- source?: string;
171
- kind?: string;
172
- payload?: Record<string, unknown>;
173
- provenance?: Record<string, unknown>;
174
- snoozed_until?: string | null;
175
- created_at?: string | null;
176
- updated_at?: string | null;
177
- };
209
+ export type ReviewItem = components["schemas"]["ReviewItem"];
210
+ export type ReviewItemList = components["schemas"]["ReviewItemList"];
211
+ export type ReviewStatusFilter = "pending" | "snoozed" | "approved" | "dismissed" | "all";
212
+ export type ReviewSourceFilter = "workflow_run" | "trigger" | "kg_change_digest" | "all";
178
213
 
179
214
  function reviewItemShape(): ReviewItem {
180
215
  return {
@@ -190,6 +225,53 @@ function reviewItemShape(): ReviewItem {
190
225
  };
191
226
  }
192
227
 
228
+ function reviewItemListShape(): ReviewItemList {
229
+ return { items: [] };
230
+ }
231
+
232
+ function reviewList(query?: { status?: Exclude<ReviewStatusFilter, "all">; source?: Exclude<ReviewSourceFilter, "all"> }) {
233
+ return openApiJson<OperationJson200<ReviewListOperation>>(
234
+ reviewItemListShape(),
235
+ (client, signal) => client.GET("/automation/reviews", {
236
+ params: { query: query || {} },
237
+ headers: workspaceHeaders(),
238
+ signal,
239
+ }),
240
+ );
241
+ }
242
+
243
+ function reviewAction(
244
+ id: string,
245
+ action: "approve" | "dismiss" | "run_now" | "unsnooze",
246
+ ) {
247
+ return openApiJson<OperationJson200<ReviewItemOperation>>(
248
+ reviewItemShape(),
249
+ (client, signal) => {
250
+ const request = {
251
+ params: { path: { item_id: id } },
252
+ headers: workspaceHeaders(),
253
+ signal,
254
+ };
255
+ if (action === "approve") return client.POST("/automation/reviews/{item_id}/approve", request);
256
+ if (action === "dismiss") return client.POST("/automation/reviews/{item_id}/dismiss", request);
257
+ if (action === "run_now") return client.POST("/automation/reviews/{item_id}/run_now", request);
258
+ return client.POST("/automation/reviews/{item_id}/unsnooze", request);
259
+ },
260
+ );
261
+ }
262
+
263
+ function snoozeReview(id: string, until: string) {
264
+ return openApiJson<OperationJson200<operations["snooze_item_automation_reviews__item_id__snooze_post"]>>(
265
+ reviewItemShape(),
266
+ (client, signal) => client.POST("/automation/reviews/{item_id}/snooze", {
267
+ params: { path: { item_id: id } },
268
+ body: { until },
269
+ headers: workspaceHeaders(),
270
+ signal,
271
+ }),
272
+ );
273
+ }
274
+
193
275
  async function uploadDocument(file: File): Promise<ApiResult<Record<string, unknown> | null>> {
194
276
  const base = await apiBase();
195
277
  const form = new FormData();
@@ -409,13 +491,12 @@ export const latticeApi = {
409
491
  hooks: () => get("/api/hooks", { hooks: [] }),
410
492
  hookRuns: () => get("/api/hooks/runs", { runs: [] }, { limit: 50 }),
411
493
  hookRun: (body: unknown) => post("/api/hooks/run", body, {}),
412
- automationReviews: (query?: { status?: string; source?: string }) =>
413
- get("/automation/reviews", { items: [] }, query),
414
- approveReviewItem: (id: string) => post(`/automation/reviews/${encodeURIComponent(id)}/approve`, {}, reviewItemShape()),
415
- dismissReviewItem: (id: string) => post(`/automation/reviews/${encodeURIComponent(id)}/dismiss`, {}, reviewItemShape()),
416
- snoozeReviewItem: (id: string, until: string) =>
417
- post(`/automation/reviews/${encodeURIComponent(id)}/snooze`, { until }, reviewItemShape()),
418
- runNowReviewItem: (id: string) => post(`/automation/reviews/${encodeURIComponent(id)}/run_now`, {}, reviewItemShape()),
494
+ automationReviews: reviewList,
495
+ approveReviewItem: (id: string) => reviewAction(id, "approve"),
496
+ dismissReviewItem: (id: string) => reviewAction(id, "dismiss"),
497
+ snoozeReviewItem: snoozeReview,
498
+ unsnoozeReviewItem: (id: string) => reviewAction(id, "unsnooze"),
499
+ runNowReviewItem: (id: string) => reviewAction(id, "run_now"),
419
500
  permissionsPending: () => get("/permissions/pending", { pending: {}, count: 0 }),
420
501
  approvePermission: (token: string) => post(`/permissions/approve/${encodeURIComponent(token)}`, {}, {}),
421
502
  denyPermission: (token: string) => post(`/permissions/deny/${encodeURIComponent(token)}`, {}, {}),
@@ -1914,6 +1914,23 @@ export interface paths {
1914
1914
  patch?: never;
1915
1915
  trace?: never;
1916
1916
  };
1917
+ "/automation/reviews/{item_id}/unsnooze": {
1918
+ parameters: {
1919
+ query?: never;
1920
+ header?: never;
1921
+ path?: never;
1922
+ cookie?: never;
1923
+ };
1924
+ get?: never;
1925
+ put?: never;
1926
+ /** Unsnooze Item */
1927
+ post: operations["unsnooze_item_automation_reviews__item_id__unsnooze_post"];
1928
+ delete?: never;
1929
+ options?: never;
1930
+ head?: never;
1931
+ patch?: never;
1932
+ trace?: never;
1933
+ };
1917
1934
  "/chat": {
1918
1935
  parameters: {
1919
1936
  query?: never;
@@ -11054,6 +11071,37 @@ export interface operations {
11054
11071
  };
11055
11072
  };
11056
11073
  };
11074
+ unsnooze_item_automation_reviews__item_id__unsnooze_post: {
11075
+ parameters: {
11076
+ query?: never;
11077
+ header?: never;
11078
+ path: {
11079
+ item_id: string;
11080
+ };
11081
+ cookie?: never;
11082
+ };
11083
+ requestBody?: never;
11084
+ responses: {
11085
+ /** @description Successful Response */
11086
+ 200: {
11087
+ headers: {
11088
+ [name: string]: unknown;
11089
+ };
11090
+ content: {
11091
+ "application/json": components["schemas"]["ReviewItem"];
11092
+ };
11093
+ };
11094
+ /** @description Validation Error */
11095
+ 422: {
11096
+ headers: {
11097
+ [name: string]: unknown;
11098
+ };
11099
+ content: {
11100
+ "application/json": components["schemas"]["HTTPValidationError"];
11101
+ };
11102
+ };
11103
+ };
11104
+ };
11057
11105
  chat_page_chat_get: {
11058
11106
  parameters: {
11059
11107
  query?: never;
@@ -53,10 +53,10 @@ export function FirstRunGuide() {
53
53
  <section className="arrival-panel" aria-label="First 10 minutes">
54
54
  <div className="arrival-copy">
55
55
  <div className="page-kicker"><CheckCircle2 className="h-4 w-4" /> First 10 minutes</div>
56
- <h2>Build your living Brain without guessing.</h2>
56
+ <h2>Start locally, with clear consent at each step.</h2>
57
57
  <p>
58
- Start with a local Brain, let Lattice recommend a model voice, then add the first pieces of durable knowledge.
59
- Every step keeps the next action visible.
58
+ Create the local Brain first, choose when to download a model, then add durable knowledge when you are ready.
59
+ Nothing needs cloud access unless you explicitly choose it.
60
60
  </p>
61
61
  <div className="arrival-actions">
62
62
  <Button onClick={() => go(nextStep.action)}>{nextStep.done ? "Open relationships" : `Continue: ${nextStep.label}`}</Button>
@@ -0,0 +1,91 @@
1
+ import { RotateCcw } from "lucide-react";
2
+ import type { ApiResult, ReviewItem } from "@/api/client";
3
+ import { ActionButton, KeyValueList } from "@/components/primitives";
4
+ import { Badge } from "@/components/ui/badge";
5
+ import { Button } from "@/components/ui/button";
6
+ import { useAppStore } from "@/store/appStore";
7
+ import {
8
+ formatSnoozedUntil,
9
+ hasRunBefore,
10
+ isActionableReview,
11
+ reviewSourceDetail,
12
+ reviewSourceLabel,
13
+ reviewStatusVariant,
14
+ type ReviewAction,
15
+ } from "./reviewHelpers";
16
+
17
+ type ReviewCardProps = {
18
+ item: ReviewItem;
19
+ feedback?: string;
20
+ onAction: (item: ReviewItem, action: ReviewAction, hadRunBefore?: boolean) => Promise<ApiResult<ReviewItem>>;
21
+ };
22
+
23
+ export function ReviewCard({ item, feedback, onAction }: ReviewCardProps) {
24
+ const mode = useAppStore((state) => state.mode);
25
+ const provenance = item.provenance || {};
26
+ const payload = item.payload || {};
27
+ const hadRun = hasRunBefore(item);
28
+ const snoozed = item.effective_status === "snoozed";
29
+ const actionable = isActionableReview(item);
30
+
31
+ return (
32
+ <div className="rounded-lg border border-border bg-background/55 p-4">
33
+ <div className="flex flex-wrap items-start justify-between gap-3">
34
+ <div className="min-w-0 flex-1">
35
+ <div className="font-medium">{item.title}</div>
36
+ {item.summary ? <p className="mt-1 text-sm leading-6 text-muted-foreground">{item.summary}</p> : null}
37
+ </div>
38
+ <div className="flex flex-wrap items-center gap-2">
39
+ <Badge variant="muted">{reviewSourceLabel(item.source)}</Badge>
40
+ <Badge variant={reviewStatusVariant(item.effective_status)}>{item.effective_status}</Badge>
41
+ </div>
42
+ </div>
43
+
44
+ {snoozed ? (
45
+ <div className="mt-3 flex flex-wrap items-center justify-between gap-3 rounded-md border border-border bg-muted/24 p-3 text-sm">
46
+ <div>
47
+ <div className="font-medium">{formatSnoozedUntil(item.snoozed_until)}</div>
48
+ <p className="mt-1 text-muted-foreground">This stays out of the pending queue until then. Unsnooze brings it back immediately.</p>
49
+ </div>
50
+ <Button size="sm" variant="outline" onClick={() => onAction(item, "unsnooze")} disabled={!actionable}>
51
+ <RotateCcw className="h-3.5 w-3.5" /> Unsnooze
52
+ </Button>
53
+ </div>
54
+ ) : null}
55
+
56
+ {mode !== "basic" ? (
57
+ <div className="mt-3">
58
+ <KeyValueList
59
+ data={{
60
+ workflow: provenance.workflow_id,
61
+ trigger: provenance.trigger_id,
62
+ run: payload.last_run_id || provenance.run_id,
63
+ source_detail: reviewSourceDetail(provenance, item.source),
64
+ snoozed_until: item.snoozed_until,
65
+ created_at: item.created_at,
66
+ updated_at: item.updated_at,
67
+ }}
68
+ limit={8}
69
+ />
70
+ </div>
71
+ ) : null}
72
+
73
+ {actionable ? (
74
+ <div className="mt-4 flex flex-wrap gap-2">
75
+ <ActionButton
76
+ label="Run now"
77
+ successLabel={hadRun ? "Regenerated" : "Executed"}
78
+ action={() => onAction(item, "run_now", hadRun)}
79
+ invalidate={[]}
80
+ />
81
+ <ActionButton label="Approve" action={() => onAction(item, "approve")} invalidate={[]} />
82
+ {!snoozed ? <ActionButton label="Snooze 1 day" action={() => onAction(item, "snooze")} invalidate={[]} /> : null}
83
+ <ActionButton label="Dismiss" action={() => onAction(item, "dismiss")} invalidate={[]} variant="destructive" />
84
+ </div>
85
+ ) : null}
86
+ {feedback ? (
87
+ <p className="mt-2 text-xs text-emerald-300">{feedback} - item stays open until you approve or dismiss.</p>
88
+ ) : null}
89
+ </div>
90
+ );
91
+ }
@@ -0,0 +1,112 @@
1
+ import * as React from "react";
2
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { latticeApi, type ReviewItem, type ReviewSourceFilter, type ReviewStatusFilter } from "@/api/client";
4
+ import { EmptyState, LoadingPanel, Tabs } from "@/components/primitives";
5
+ import { Badge } from "@/components/ui/badge";
6
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
7
+ import { ReviewCard } from "./ReviewCard";
8
+ import {
9
+ defaultSnoozeUntil,
10
+ reviewSourceFilters,
11
+ reviewStatusFilters,
12
+ type ReviewAction,
13
+ } from "./reviewHelpers";
14
+
15
+ export function ReviewInbox() {
16
+ const qc = useQueryClient();
17
+ const [statusFilter, setStatusFilter] = React.useState<ReviewStatusFilter>("pending");
18
+ const [sourceFilter, setSourceFilter] = React.useState<ReviewSourceFilter>("all");
19
+ const [runFeedback, setRunFeedback] = React.useState<Record<string, string>>({});
20
+ const reviews = useQuery({
21
+ queryKey: ["automationReviews", statusFilter, sourceFilter],
22
+ queryFn: () => latticeApi.automationReviews({
23
+ ...(statusFilter !== "all" ? { status: statusFilter } : {}),
24
+ ...(sourceFilter !== "all" ? { source: sourceFilter } : {}),
25
+ }),
26
+ });
27
+ const items = reviews.data?.data.items || [];
28
+
29
+ const actOnReview = async (
30
+ item: ReviewItem,
31
+ action: ReviewAction,
32
+ hadRunBefore = false,
33
+ ) => {
34
+ const call =
35
+ action === "approve" ? () => latticeApi.approveReviewItem(item.id) :
36
+ action === "dismiss" ? () => latticeApi.dismissReviewItem(item.id) :
37
+ action === "snooze" ? () => latticeApi.snoozeReviewItem(item.id, defaultSnoozeUntil()) :
38
+ action === "unsnooze" ? () => latticeApi.unsnoozeReviewItem(item.id) :
39
+ () => latticeApi.runNowReviewItem(item.id);
40
+ const result = await call();
41
+ if (result.ok) {
42
+ if (action === "run_now") {
43
+ setRunFeedback((prev) => ({
44
+ ...prev,
45
+ [item.id]: hadRunBefore ? "Regenerated" : "Executed",
46
+ }));
47
+ } else {
48
+ setRunFeedback((prev) => {
49
+ const next = { ...prev };
50
+ delete next[item.id];
51
+ return next;
52
+ });
53
+ }
54
+ await qc.invalidateQueries({ queryKey: ["automationReviews"] });
55
+ }
56
+ return result;
57
+ };
58
+
59
+ if (reviews.isLoading) return <LoadingPanel title="Review inbox" />;
60
+
61
+ return (
62
+ <Card>
63
+ <CardHeader className="gap-3">
64
+ <div className="flex flex-wrap items-start justify-between gap-3">
65
+ <div>
66
+ <CardTitle>Review inbox</CardTitle>
67
+ <CardDescription>Automation suggestions waiting for your decision. Run now executes without approving.</CardDescription>
68
+ </div>
69
+ {reviews.data ? (
70
+ <Badge variant={reviews.data.ok ? "success" : "warning"}>{reviews.data.ok ? "connected" : "unavailable"}</Badge>
71
+ ) : null}
72
+ </div>
73
+ <div className="grid gap-2">
74
+ <Tabs
75
+ tabs={reviewStatusFilters}
76
+ value={statusFilter}
77
+ onChange={(id) => setStatusFilter(id as ReviewStatusFilter)}
78
+ />
79
+ <Tabs
80
+ tabs={reviewSourceFilters}
81
+ value={sourceFilter}
82
+ onChange={(id) => setSourceFilter(id as ReviewSourceFilter)}
83
+ />
84
+ </div>
85
+ </CardHeader>
86
+ <CardContent>
87
+ {reviews.isError || (reviews.data && !reviews.data.ok) ? (
88
+ <EmptyState
89
+ title="Could not load review inbox"
90
+ detail={reviews.data?.error || "The review queue is not available right now."}
91
+ />
92
+ ) : !items.length ? (
93
+ <EmptyState
94
+ title="Nothing to review"
95
+ detail={statusFilter === "snoozed" ? "Snoozed items will appear here until they are unsnoozed or become pending again." : "When automations opt into the review queue, new suggestions will appear here."}
96
+ />
97
+ ) : (
98
+ <div className="grid gap-3">
99
+ {items.map((item) => (
100
+ <ReviewCard
101
+ key={item.id}
102
+ item={item}
103
+ feedback={runFeedback[item.id]}
104
+ onAction={actOnReview}
105
+ />
106
+ ))}
107
+ </div>
108
+ )}
109
+ </CardContent>
110
+ </Card>
111
+ );
112
+ }