Compare commits

...

4 Commits

Author SHA1 Message Date
d36ca43804 Bug fix send button 2026-03-10 09:10:57 +01:00
b06f5f6022 step 6.1 complete: auth gate in AppShell + LoginForm
- LoginForm.tsx: centered login/register screen with spring animations
- AppShell: queries auth.status on startup; renders LoginForm full-screen when authenticated === false; passes through while loading to avoid flicker
- Settings AccountSection: removed inline login form (AppShell now gates auth); always shows account info + sign out

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 20:54:44 +01:00
c3f298e384 steps 5-8: block renderers, chat integration, floating domain nav, v2 cleanup
- Add block renderer components (chart, entity, table, timeline) with
  shadcn chart/table and spring entrance animations
- Integrate BlockRenderer into AIChatPanel for inline block display
- Refactor FloatingChat to use floating_domain signals for background
  navigation, remove v2 section tag mechanism and dead onAction handler
- Remove v2 chat schemas from api-types.ts (ChatContext, ChatRequest,
  ChatResponse, WsChatRequest, WsTextChunk, WsFinal)
- Fix daily brief onStreamChunk → onStreamEvent migration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:56:22 +01:00
733a3c16a8 steps 1-4: v3 ws streaming pipeline
- Step 1: add v3 frame types to api-types.ts (WsHomeRequest,
  WsFloatingRequest, WsStreamStart/Text/Block/End, WsFloatingDomain,
  block data interfaces)
- Step 2: unify chat onto persistent device WS (backend-client.ts) —
  sendHomeRequest/sendFloatingRequest with StreamListener map;
  remove chatStream/openChatWebSocket
- Step 3: refactor orchestrator to v3 (orchestrator.ts) — remove
  buildChatContext/sendStreamChunk, add orchestrateFloating;
  update preload onStreamChunk→onStreamEvent, remove onAction;
  update aiRouter.chat input for mode/scope/conversationHistory
- Step 4: update useAIChat for v3 structured streaming — StreamBlock
  type, onStreamEvent handler, streamingBlocks state, onDomainSignal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 09:00:27 +01:00
28 changed files with 2479 additions and 607 deletions

View File

@@ -3,7 +3,11 @@
"allow": [
"Bash(git add AI_REFACTOR_PLAN.md)",
"Bash(git commit:*)",
"Read(//home/rmusso/adiuva-api/**)"
"Read(//home/rmusso/adiuva-api/**)",
"mcp__shadcn__get_item_examples_from_registries",
"mcp__shadcn__view_items_in_registries",
"Bash(npm run lint)",
"Bash(npx eslint --ext .ts,.tsx src/renderer/components/ai/blocks/)"
]
}
}

View File

@@ -375,8 +375,10 @@ Key constraints:
## Phase 6 — Renderer UI Updates
### Step 6.1 — Auth UI + settings restructure
- [ ] `LoginForm.tsx`, auth gate in AppShell, `SettingsPage.tsx` (Account, Backup, Permissions tabs — no AI Providers tab)
### Step 6.1 — Auth UI + settings restructure
- [x] `LoginForm.tsx` — centered login/register screen (`src/renderer/components/auth/LoginForm.tsx`)
- [x] Auth gate in `AppShell` — shows `LoginForm` when `auth.status` returns `authenticated: false`; passes through while loading to avoid flicker; `staleTime: 5min` to avoid hammering backend
- [x] `SettingsPage.tsx` Account section simplified — login form removed (AppShell handles it), always shows profile + sign out
### Step 6.2 — ChatPage with context panel ❌ DEPRECATED
> **Superseded by V3.** Home chat with block rendering (charts, entities, tables, timelines) and FloatingChat with domain navigation replace this. See `V3_ELECTRON_MIGRATION_PLAN.md` Steps 47.

View File

@@ -62,7 +62,7 @@ source ~/.nvm/nvm.sh && npm run lint
```
**Status**:
- [ ] Step 1 complete
- [x] Step 1 complete
**Commit**:
```
@@ -105,7 +105,7 @@ source ~/.nvm/nvm.sh && npm start
```
**Status**:
- [ ] Step 2 complete
- [x] Step 2 complete
**Commit**:
```
@@ -155,7 +155,7 @@ source ~/.nvm/nvm.sh && npm start
```
**Status**:
- [ ] Step 3 complete
- [x] Step 3 complete
**Commit**:
```
@@ -206,7 +206,7 @@ source ~/.nvm/nvm.sh && npm start
```
**Status**:
- [ ] Step 4 complete
- [x] Step 4 complete
**Commit**:
```
@@ -256,7 +256,7 @@ source ~/.nvm/nvm.sh && npm start
```
**Status**:
- [ ] Step 5 complete
- [x] Step 5 complete
**Commit**:
```
@@ -290,7 +290,7 @@ source ~/.nvm/nvm.sh && npm start
```
**Status**:
- [ ] Step 6 complete
- [x] Step 6 complete
**Commit**:
```
@@ -327,7 +327,7 @@ source ~/.nvm/nvm.sh && npm start
```
**Status**:
- [ ] Step 7 complete
- [x] Step 7 complete
**Commit**:
```
@@ -364,7 +364,7 @@ source ~/.nvm/nvm.sh && npm run lint && npm start
```
**Status**:
- [ ] Step 8 complete
- [x] Step 8 complete
**Commit**:
```

347
package-lock.json generated
View File

@@ -36,6 +36,7 @@
"react-day-picker": "^9.13.2",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"recharts": "^2.15.4",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
@@ -7941,6 +7942,69 @@
"license": "MIT",
"peer": true
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -10747,6 +10811,127 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
@@ -10868,6 +11053,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/decode-named-character-reference": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@@ -11168,6 +11359,16 @@
"node": ">=6.0.0"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
@@ -13569,6 +13770,15 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -14927,6 +15137,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/interpret": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz",
@@ -15688,7 +15907,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -16418,6 +16636,18 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lop": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
@@ -18235,7 +18465,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -19251,6 +19480,23 @@
"node": ">=6"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@@ -19815,6 +20061,12 @@
"react": "^19.2.4"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@@ -19934,6 +20186,21 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"license": "MIT",
"dependencies": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -19962,6 +20229,22 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/read-binary-file-arch": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
@@ -20131,6 +20414,44 @@
"dev": true,
"license": "0BSD"
},
"node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
"license": "MIT",
"dependencies": {
"decimal.js-light": "^2.4.1"
}
},
"node_modules/recharts/node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/rechoir": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz",
@@ -23829,6 +24150,28 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",

View File

@@ -75,6 +75,7 @@
"react-day-picker": "^9.13.2",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"recharts": "^2.15.4",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",

View File

@@ -1,21 +1,19 @@
/**
* @Orchestrator — thin backend-delegation layer.
* @Orchestrator — thin backend-delegation layer (v3).
*
* All AI intelligence lives on the backend. The Electron process:
* 1. Checks connectivity + auth status
* 2. Assembles a context snapshot from local SQLite
* 3. Delegates to BackendClient.chatStream() — which handles the WS lifecycle,
* tool-call ↔ DrizzleExecutor round-trips, and text streaming.
*
* @see AI_REFACTOR_PLAN.md — Phase 1, Step 1.5
* 2. Delegates to BackendClient.sendHomeRequest() / sendFloatingRequest()
* which handle the WS lifecycle, tool-call ↔ DrizzleExecutor round-trips,
* and v3 stream event dispatch.
* 3. Forwards v3 typed stream frames to the renderer via IPC.
*/
import { eq, asc } from 'drizzle-orm';
import { getDb } from '../db';
import { tasks, projects, clients, notes, checkpoints } from '../db/schema';
import { BrowserWindow } from 'electron';
import { getBackendClient, OfflineError, AuthExpiredError } from '../api/backend-client';
import { getAuthManager } from '../auth/auth-manager';
import type { ChatContext } from '../../shared/api-types';
import { getStore } from '../store';
import type { WsFloatingRequest } from '../../shared/api-types';
// ---------------------------------------------------------------------------
// Constants
@@ -29,165 +27,109 @@ const AI_STREAM_CHANNEL = 'ai:stream';
interface OrchestrateInput {
message: string;
context: { type: 'global' | 'project'; projectId?: string; uiContext?: string };
conversationHistory?: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>;
sender?: Electron.WebContents;
}
interface OrchestrateFloatingInput {
message: string;
scope: WsFloatingRequest['scope'];
sender?: Electron.WebContents;
}
/**
* @deprecated Superseded by `ChatResponse` from `@shared/api-types`.
* Kept for interface compatibility with `aiRouter` until Step 2.1.
*/
interface OrchestrateResult {
response: string;
error?: string;
}
// ---------------------------------------------------------------------------
// Context assembly — SQLite snapshot → ChatContext
// IPC helper
// ---------------------------------------------------------------------------
function buildChatContext(input: OrchestrateInput['context']): ChatContext {
const db = getDb();
// Recent tasks (last 20, ordered by creation time)
const recentTasks = db
.select({ id: tasks.id, title: tasks.title, status: tasks.status })
.from(tasks)
.orderBy(asc(tasks.createdAt))
.limit(20)
.all()
.map((t) => ({ id: t.id, title: t.title, status: t.status }));
const relevantDocuments: string[] = [];
if (input.type === 'project' && input.projectId) {
const projectId = input.projectId;
const project = db.select().from(projects).where(eq(projects.id, projectId)).all()[0];
if (project) {
let clientName = '';
if (project.clientId) {
const client = db.select().from(clients).where(eq(clients.id, project.clientId)).all()[0];
if (client) clientName = client.name;
}
const projectTasks = db
.select({ title: tasks.title, status: tasks.status, priority: tasks.priority, dueDate: tasks.dueDate })
.from(tasks)
.where(eq(tasks.projectId, projectId))
.orderBy(asc(tasks.createdAt))
.all();
const projectCheckpoints = db
.select({ title: checkpoints.title, date: checkpoints.date })
.from(checkpoints)
.where(eq(checkpoints.projectId, projectId))
.orderBy(asc(checkpoints.date))
.all();
const projectNotes = db
.select({ title: notes.title, content: notes.content })
.from(notes)
.where(eq(notes.projectId, projectId))
.orderBy(asc(notes.createdAt))
.all();
const summaryLines: string[] = [
`Project: ${project.name}`,
clientName ? `Client: ${clientName}` : '',
`Status: ${project.status}`,
project.aiSummary ? `Summary: ${project.aiSummary}` : '',
projectTasks.length > 0
? `Tasks (${projectTasks.length}): ${projectTasks.map((t) => `${t.title} [${t.status}]`).join(', ')}`
: '',
projectCheckpoints.length > 0
? `Checkpoints: ${projectCheckpoints.map((c) => `${c.title} (${new Date(c.date).toLocaleDateString()})`).join(', ')}`
: '',
projectNotes.length > 0
? `Notes: ${projectNotes.map((n) => n.title).join(', ')}`
: '',
].filter(Boolean);
relevantDocuments.push(summaryLines.join('\n'));
// Include note contents as individual documents (capped at 500 chars each)
for (const note of projectNotes) {
const excerpt = note.content.length > 500 ? note.content.slice(0, 500) + '…' : note.content;
if (excerpt.trim()) {
relevantDocuments.push(`## ${note.title}\n${excerpt}`);
}
}
}
} else {
// Global context: workspace summary
const allProjects = db
.select({ id: projects.id, name: projects.name, status: projects.status })
.from(projects)
.where(eq(projects.status, 'active'))
.orderBy(asc(projects.name))
.all();
if (allProjects.length > 0) {
relevantDocuments.push(
`Active projects (${allProjects.length}): ${allProjects.map((p) => p.name).join(', ')}`,
);
}
}
return {
recentTasks,
relevantDocuments: relevantDocuments.length > 0 ? relevantDocuments : undefined,
};
}
// ---------------------------------------------------------------------------
// Streaming helper
// ---------------------------------------------------------------------------
export function sendStreamChunk(
sender: Electron.WebContents | undefined,
token: string,
done: boolean,
): void {
function sendFrame(sender: Electron.WebContents | undefined, payload: Record<string, unknown>): void {
if (!sender || sender.isDestroyed()) return;
sender.send(AI_STREAM_CHANNEL, { token, done });
sender.send(AI_STREAM_CHANNEL, payload);
}
// ---------------------------------------------------------------------------
// Orchestrate (public entry point)
// Connectivity + auth guard (shared)
// ---------------------------------------------------------------------------
async function checkConnectivity(): Promise<{ ok: true } | { ok: false; error: string }> {
const client = getBackendClient();
const online = await client.isOnline();
if (!online) {
return { ok: false, error: 'You are offline. AI features require an internet connection.' };
}
const authenticated = await getAuthManager().isAuthenticated();
if (!authenticated) {
return { ok: false, error: 'Please log in to use AI features.' };
}
return { ok: true };
}
// ---------------------------------------------------------------------------
// Orchestrate — Home chat (public entry point)
// ---------------------------------------------------------------------------
export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateResult> {
const { message, context, sender } = input;
const { message, conversationHistory, sender } = input;
const check = await checkConnectivity();
if (!check.ok) return { response: '', error: check.error };
try {
const client = getBackendClient();
const { requestId, promise } = client.sendHomeRequest(message, conversationHistory, {
onStart: () => sendFrame(sender, { type: 'stream_start', requestId }),
onText: (chunk) => sendFrame(sender, { type: 'stream_text', requestId, chunk }),
onBlock: (blockType, data) => sendFrame(sender, { type: 'stream_block', requestId, blockType, data }),
onEnd: () => sendFrame(sender, { type: 'stream_end', requestId }),
onError: () => sendFrame(sender, { type: 'stream_end', requestId }),
});
// 1. Connectivity check
const online = await client.isOnline();
if (!online) {
await promise;
return { response: 'ok' };
} catch (err) {
sendFrame(sender, { type: 'stream_end', requestId: '' });
if (err instanceof OfflineError) {
return { response: '', error: 'You are offline. AI features require an internet connection.' };
}
// 2. Auth check
const authenticated = await getAuthManager().isAuthenticated();
if (!authenticated) {
return { response: '', error: 'Please log in to use AI features.' };
if (err instanceof AuthExpiredError) {
return { response: '', error: 'Your session has expired. Please log in again.' };
}
// 3. Build context snapshot from local SQLite
const chatContext = buildChatContext(context);
const errMsg = err instanceof Error ? err.message : String(err);
return { response: '', error: errMsg };
}
}
// 4. Stream from backend — tool calls are handled inside BackendClient.chatStream()
const result = await client.chatStream(
{ message, context: chatContext },
(chunk) => sendStreamChunk(sender, chunk, false),
);
// ---------------------------------------------------------------------------
// Orchestrate Floating — Floating chat (public entry point)
// ---------------------------------------------------------------------------
sendStreamChunk(sender, '', true);
return { response: result.response };
export async function orchestrateFloating(input: OrchestrateFloatingInput): Promise<OrchestrateResult> {
const { message, scope, sender } = input;
const check = await checkConnectivity();
if (!check.ok) return { response: '', error: check.error };
try {
const client = getBackendClient();
const { requestId, promise } = client.sendFloatingRequest(message, scope, {
onStart: () => sendFrame(sender, { type: 'stream_start', requestId }),
onText: (chunk) => sendFrame(sender, { type: 'stream_text', requestId, chunk }),
onBlock: (blockType, data) => sendFrame(sender, { type: 'stream_block', requestId, blockType, data }),
onEnd: () => sendFrame(sender, { type: 'stream_end', requestId }),
onDomain: (domain) => sendFrame(sender, { type: 'floating_domain', requestId, domain }),
onError: () => sendFrame(sender, { type: 'stream_end', requestId }),
});
await promise;
return { response: 'ok' };
} catch (err) {
sendStreamChunk(sender, '', true);
sendFrame(sender, { type: 'stream_end', requestId: '' });
if (err instanceof OfflineError) {
return { response: '', error: 'You are offline. AI features require an internet connection.' };
@@ -206,21 +148,95 @@ export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateR
// ---------------------------------------------------------------------------
const DAILY_BRIEF_PROMPT =
`Act as a professional and efficient executive assistant. Give me a concise daily brief for today.
`Act as a professional and efficient executive assistant. Give me a concise daily brief for today.
Strict Rules:
- Adopt a polite, formal, and helpful tone. Do not use emojis, slang, or overly casual encouragement.
- Focus strictly on actionable or critical items: tasks due today, upcoming deadlines this week, overdue items, and significant project activity.
- Focus strictly on actionable or critical items: tasks due today, upcoming deadlines this week, overdue items, and significant project activity.
- Do NOT mention zero-counts (e.g., "no overdue items") or general statistics (e.g., "2 active projects", "2 completed tasks"). Only report what needs my attention.
- Do NOT include any headers, titles, dates, or greetings.
- Do NOT use labels like "Due today:" or "Overdue:". Integrate the information naturally into sentences.
- Use **bold** for key phrases, task names, or project names.
- Keep the entire response to 3-5 sentences.`;
export async function dailyBrief(sender?: Electron.WebContents): Promise<OrchestrateResult> {
return orchestrate({
message: DAILY_BRIEF_PROMPT,
context: { type: 'global' },
sender,
});
function todayString(): string {
return new Date().toISOString().slice(0, 10);
}
/** Returns cached brief content if it was generated today, otherwise null. */
export function getCachedBrief(): string | null {
const cache = getStore().get('dailyBriefCache');
if (!cache || cache.date !== todayString()) return null;
return cache.content;
}
/** Invalidate the cache so the next home visit triggers a fresh generation. */
export function invalidateBriefCache(): void {
getStore().set('dailyBriefCache', null);
}
/** Regenerate the brief silently in background, cache it, then push to all windows. */
export async function generateAndCacheBrief(): Promise<void> {
const check = await checkConnectivity();
if (!check.ok) return;
let content = '';
try {
const client = getBackendClient();
const { promise } = client.sendHomeRequest(DAILY_BRIEF_PROMPT, undefined, {
onStart: () => {},
onText: (chunk) => { content += chunk; },
onBlock: () => {},
onEnd: () => {},
onError: () => {},
});
await promise;
} catch {
return;
}
if (!content) return;
getStore().set('dailyBriefCache', { content, date: todayString() });
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send('ai:brief-updated', content);
}
}
}
/** Stream the daily brief to the renderer and cache the result. */
export async function dailyBrief(sender?: Electron.WebContents): Promise<OrchestrateResult> {
let content = '';
const check = await checkConnectivity();
if (!check.ok) return { response: '', error: check.error };
try {
const client = getBackendClient();
const requestId = crypto.randomUUID();
const { promise } = client.sendHomeRequest(DAILY_BRIEF_PROMPT, undefined, {
onStart: () => sendFrame(sender, { type: 'stream_start', requestId }),
onText: (chunk) => {
content += chunk;
sendFrame(sender, { type: 'stream_text', requestId, chunk });
},
onBlock: () => {},
onEnd: () => sendFrame(sender, { type: 'stream_end', requestId }),
onError: () => sendFrame(sender, { type: 'stream_end', requestId }),
});
await promise;
} catch (err) {
sendFrame(sender, { type: 'stream_end', requestId: '' });
if (err instanceof OfflineError) return { response: '', error: 'You are offline.' };
if (err instanceof AuthExpiredError) return { response: '', error: 'Your session has expired. Please log in again.' };
return { response: '', error: err instanceof Error ? err.message : String(err) };
}
if (content) {
getStore().set('dailyBriefCache', { content, date: todayString() });
}
return { response: 'ok' };
}

View File

@@ -23,19 +23,45 @@ import { getAuthManager } from '../auth/auth-manager';
import { toSnakeCase, toCamelCase } from '../../shared/casing';
import {
WsServerFrameSchema,
ChatResponseSchema,
} from '../../shared/api-types';
import type {
ChatRequest,
ChatResponse,
WsToolResult,
WsAgentRun,
WsAgentData,
LocalAgentConfig,
WsFloatingRequest,
} from '../../shared/api-types';
import { DrizzleExecutor } from './drizzle-executor';
import { readAgentFiles } from '../agents/file-reader';
// ---------------------------------------------------------------------------
// Dev-mode logger
// ---------------------------------------------------------------------------
const IS_DEV = process.env.NODE_ENV === 'development';
function logHttp(method: string, url: string, body?: unknown): void {
if (!IS_DEV) return;
const bodyStr = body !== undefined ? `\n body: ${JSON.stringify(body)}` : '';
console.log(`[BE ▶ HTTP] ${method} ${url}${bodyStr}`);
}
function logHttpResponse(method: string, url: string, status: number, body?: unknown): void {
if (!IS_DEV) return;
const bodyStr = body !== undefined ? `\n response: ${JSON.stringify(body).slice(0, 500)}` : '';
console.log(`[BE ◀ HTTP] ${status} ${method} ${url}${bodyStr}`);
}
function logWsSend(payload: unknown): void {
if (!IS_DEV) return;
console.log(`[BE ▶ WS] ${JSON.stringify(payload)}`);
}
function logWsRecv(payload: unknown): void {
if (!IS_DEV) return;
console.log(`[BE ◀ WS] ${JSON.stringify(payload)}`);
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
@@ -45,8 +71,6 @@ const HTTP_TIMEOUT_MS = 10_000;
const MAX_RETRIES = 3;
/** Base delay for exponential backoff (ms). */
const RETRY_BASE_MS = 500;
/** Maximum iterations the backend may request before we force-close. */
const MAX_TOOL_ITERATIONS = 10;
/** Interval between client-side heartbeat pings on the persistent device WS. */
const HEARTBEAT_INTERVAL_MS = 30_000;
/** Time to wait for any response after a ping before treating the connection as dead. */
@@ -91,6 +115,21 @@ export class ServerError extends Error {
}
}
// ---------------------------------------------------------------------------
// V3 stream listener types
// ---------------------------------------------------------------------------
interface StreamListener {
onStart: () => void;
onText: (chunk: string) => void;
onBlock: (blockType: string, data: Record<string, unknown>) => void;
onEnd: (mutations?: Record<string, unknown>) => void;
onDomain: (domain: string) => void;
onError: (err: Error) => void;
resolve: () => void;
reject: (err: Error) => void;
}
// ---------------------------------------------------------------------------
// BackendClient
// ---------------------------------------------------------------------------
@@ -99,7 +138,7 @@ export class BackendClient {
private static instance: BackendClient | null = null;
private executor = new DrizzleExecutor();
// Persistent device WebSocket state (Step 3.5)
// Persistent device WebSocket state
private persistentWs: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
@@ -111,6 +150,9 @@ export class BackendClient {
/** Optional callback fired when the persistent WS successfully connects. */
private onConnectedCallback: (() => void) | null = null;
/** V3 stream listeners keyed by requestId. */
private streamListeners: Map<string, StreamListener> = new Map();
// eslint-disable-next-line @typescript-eslint/no-empty-function
private constructor() {}
@@ -141,9 +183,11 @@ export class BackendClient {
/** Returns `true` if the backend responds within 3 s, `false` otherwise. */
async isOnline(): Promise<boolean> {
try {
logHttp('GET', `${this.baseUrl}/api/v1/health`);
const res = await fetch(`${this.baseUrl}/api/v1/health`, {
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
});
logHttpResponse('GET', `${this.baseUrl}/api/v1/health`, res.status);
return res.ok;
} catch {
return false;
@@ -160,6 +204,7 @@ export class BackendClient {
const token = await getAuthManager().getAccessToken();
if (!token) throw new AuthExpiredError();
logHttp('POST', `${this.baseUrl}/api/v1/storage/vectors/embed`, { text: text.slice(0, 80) + '…' });
const res = await fetch(`${this.baseUrl}/api/v1/storage/vectors/embed`, {
method: 'POST',
headers: {
@@ -170,6 +215,7 @@ export class BackendClient {
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
});
logHttpResponse('POST', `${this.baseUrl}/api/v1/storage/vectors/embed`, res.status);
await this.assertHttpOk(res);
const data = toCamelCase<{ vector: number[] }>(await res.json());
return data.vector;
@@ -177,134 +223,98 @@ export class BackendClient {
}
// -------------------------------------------------------------------------
// Chat stream (bidirectional WebSocket)
// V3 Chat — send via persistent device WS
// -------------------------------------------------------------------------
/**
* Open a WebSocket to the backend, stream chat, and handle tool calls
* in-band via the Drizzle executor.
*
* @param request The enriched chat request (context includes DB snapshot).
* @param onChunk Called with each text token as it streams in.
* @returns The final response once the backend sends a `final` frame.
* Send a home chat request over the persistent device WS.
* Registers a stream listener keyed by `requestId` and returns a promise
* that resolves when `stream_end` arrives.
*/
async chatStream(
request: ChatRequest,
onChunk: (text: string) => void,
): Promise<ChatResponse> {
return this.withRetry(
async () => {
const token = await getAuthManager().getAccessToken();
if (!token) throw new AuthExpiredError();
sendHomeRequest(
message: string,
conversationHistory?: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>,
callbacks?: Partial<Omit<StreamListener, 'resolve' | 'reject'>>,
): { requestId: string; promise: Promise<void> } {
const requestId = crypto.randomUUID();
const promise = new Promise<void>((resolve, reject) => {
this.streamListeners.set(requestId, {
onStart: callbacks?.onStart ?? ((): void => { /* no-op */ }),
onText: callbacks?.onText ?? ((): void => { /* no-op */ }),
onBlock: callbacks?.onBlock ?? ((): void => { /* no-op */ }),
onEnd: (mutations) => {
callbacks?.onEnd?.(mutations);
this.streamListeners.delete(requestId);
resolve();
},
onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
onError: (err) => {
callbacks?.onError?.(err);
this.streamListeners.delete(requestId);
reject(err);
},
resolve,
reject,
});
return this.openChatWebSocket(request, token, onChunk);
},
// Don't retry auth failures
(err) => !(err instanceof AuthExpiredError),
);
const ws = this.persistentWs;
if (!ws || ws.readyState !== WebSocket.OPEN) {
this.streamListeners.delete(requestId);
reject(new OfflineError('Persistent WS not connected'));
return;
}
const homePayload = toSnakeCase({ type: 'home_request', requestId, message, conversationHistory });
logWsSend(homePayload);
ws.send(JSON.stringify(homePayload));
});
return { requestId, promise };
}
// -------------------------------------------------------------------------
// WebSocket internals
// -------------------------------------------------------------------------
private openChatWebSocket(
request: ChatRequest,
token: string,
onChunk: (text: string) => void,
): Promise<ChatResponse> {
return new Promise((resolve, reject) => {
const wsUrl = `${this.wsBaseUrl}/api/v1/chat/stream?token=${encodeURIComponent(token)}`;
const ws = new WebSocket(wsUrl);
let toolIteration = 0;
let settled = false;
const finish = (err?: Error) => {
if (settled) return;
settled = true;
try { ws.close(); } catch { /* ignore */ }
if (err) reject(err);
};
ws.on('open', () => {
const frame = toSnakeCase({
type: 'chat_request',
message: request.message,
context: request.context,
});
ws.send(JSON.stringify(frame));
/**
* Send a floating chat request over the persistent device WS.
* Same listener pattern as `sendHomeRequest`.
*/
sendFloatingRequest(
message: string,
scope: WsFloatingRequest['scope'],
callbacks?: Partial<Omit<StreamListener, 'resolve' | 'reject'>>,
): { requestId: string; promise: Promise<void> } {
const requestId = crypto.randomUUID();
const promise = new Promise<void>((resolve, reject) => {
this.streamListeners.set(requestId, {
onStart: callbacks?.onStart ?? ((): void => { /* no-op */ }),
onText: callbacks?.onText ?? ((): void => { /* no-op */ }),
onBlock: callbacks?.onBlock ?? ((): void => { /* no-op */ }),
onEnd: (mutations) => {
callbacks?.onEnd?.(mutations);
this.streamListeners.delete(requestId);
resolve();
},
onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
onError: (err) => {
callbacks?.onError?.(err);
this.streamListeners.delete(requestId);
reject(err);
},
resolve,
reject,
});
ws.on('message', (raw: Buffer | string) => {
const text = typeof raw === 'string' ? raw : raw.toString('utf8');
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return; // Malformed frame — ignore
}
const ws = this.persistentWs;
if (!ws || ws.readyState !== WebSocket.OPEN) {
this.streamListeners.delete(requestId);
reject(new OfflineError('Persistent WS not connected'));
return;
}
const frame = WsServerFrameSchema.safeParse(toCamelCase(parsed));
if (!frame.success) return; // Unknown frame type — ignore
switch (frame.data.type) {
case 'text_chunk':
onChunk(frame.data.text);
break;
case 'tool_call': {
if (++toolIteration > MAX_TOOL_ITERATIONS) {
finish(new ServerError('Exceeded maximum tool call iterations', 500));
return;
}
const toolCall = frame.data;
// Fire-and-forget async; errors are sent back as tool_result.error
void (async () => {
let result: WsToolResult;
try {
const output = await this.executor.execute(toolCall);
result = { type: 'tool_result', id: toolCall.id, ...output } as WsToolResult;
} catch (err) {
const msg = err instanceof Error ? err.message : 'Executor error';
result = { type: 'tool_result', id: toolCall.id, error: msg };
}
if (!settled) {
ws.send(JSON.stringify(toSnakeCase(result)));
}
})();
break;
}
case 'final': {
const validated = ChatResponseSchema.safeParse({ response: frame.data.response });
if (!settled) {
settled = true;
try { ws.close(); } catch { /* ignore */ }
if (validated.success) {
resolve(validated.data);
} else {
resolve({ response: frame.data.response });
}
}
break;
}
case 'ping':
// No-op — keep-alive from server
break;
}
});
ws.on('error', (err: Error) => {
finish(new OfflineError(`WebSocket error: ${err.message}`));
});
ws.on('close', (code: number) => {
if (!settled) {
finish(new OfflineError(`WebSocket closed unexpectedly (code ${code})`));
}
});
const floatingPayload = toSnakeCase({ type: 'floating_request', requestId, message, scope });
logWsSend(floatingPayload);
ws.send(JSON.stringify(floatingPayload));
});
return { requestId, promise };
}
// -------------------------------------------------------------------------
@@ -332,12 +342,14 @@ export class BackendClient {
const token = await getAuthManager().getAccessToken();
if (!token) throw new AuthExpiredError();
logHttp('GET', `${this.baseUrl}${path}`);
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
});
logHttpResponse('GET', `${this.baseUrl}${path}`, res.status);
await this.assertHttpOk(res);
return toCamelCase<T>(await res.json());
},
@@ -352,6 +364,7 @@ export class BackendClient {
const token = await getAuthManager().getAccessToken();
if (!token) throw new AuthExpiredError();
logHttp('POST', `${this.baseUrl}${path}`, body);
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'POST',
headers: {
@@ -362,6 +375,7 @@ export class BackendClient {
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
});
logHttpResponse('POST', `${this.baseUrl}${path}`, res.status);
await this.assertHttpOk(res);
return toCamelCase<T>(await res.json());
},
@@ -376,6 +390,7 @@ export class BackendClient {
const token = await getAuthManager().getAccessToken();
if (!token) throw new AuthExpiredError();
logHttp('PUT', `${this.baseUrl}${path}`, body);
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'PUT',
headers: {
@@ -386,6 +401,7 @@ export class BackendClient {
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
});
logHttpResponse('PUT', `${this.baseUrl}${path}`, res.status);
await this.assertHttpOk(res);
return toCamelCase<T>(await res.json());
},
@@ -400,12 +416,14 @@ export class BackendClient {
const token = await getAuthManager().getAccessToken();
if (!token) throw new AuthExpiredError();
logHttp('DELETE', `${this.baseUrl}${path}`);
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
});
logHttpResponse('DELETE', `${this.baseUrl}${path}`, res.status);
await this.assertHttpOk(res);
return toCamelCase<T>(await res.json());
},
@@ -608,6 +626,7 @@ export class BackendClient {
return;
}
logWsRecv(parsed);
const frame = WsServerFrameSchema.safeParse(toCamelCase(parsed));
if (!frame.success) return;
@@ -631,6 +650,7 @@ export class BackendClient {
result = { type: 'tool_result', id: toolCall.id, error: msg };
}
if (ws.readyState === WebSocket.OPEN) {
logWsSend(result);
ws.send(JSON.stringify(toSnakeCase(result)));
}
})();
@@ -641,10 +661,36 @@ export class BackendClient {
// Server keep-alive — no-op
break;
case 'text_chunk':
case 'final':
// Chat-only frames — ignore on persistent device WS
// V3 streaming frames — dispatch to registered listener
case 'stream_start': {
const listener = this.streamListeners.get(frame.data.requestId);
listener?.onStart();
break;
}
case 'stream_text': {
const listener = this.streamListeners.get(frame.data.requestId);
listener?.onText(frame.data.chunk);
break;
}
case 'stream_block': {
const listener = this.streamListeners.get(frame.data.requestId);
listener?.onBlock(frame.data.blockType, frame.data.data);
break;
}
case 'stream_end': {
const listener = this.streamListeners.get(frame.data.requestId);
listener?.onEnd(frame.data.mutations);
break;
}
case 'floating_domain': {
const listener = this.streamListeners.get(frame.data.requestId);
listener?.onDomain(frame.data.domain);
break;
}
}
});
@@ -664,6 +710,16 @@ export class BackendClient {
this.persistentWs = null;
this.stopHeartbeat();
console.log(`[DeviceWS] Connection closed (code ${code}).`);
// Reject any in-flight stream listeners so callers don't hang forever.
if (this.streamListeners.size > 0) {
const err = new Error('WebSocket connection closed');
for (const listener of this.streamListeners.values()) {
try { listener.onError(err); } catch { /* ignore */ }
}
this.streamListeners.clear();
}
if (this.shouldReconnect) {
this.scheduleReconnect();
}

View File

@@ -7,7 +7,7 @@ import { clients, projects, tasks, checkpoints, notes, taskComments } from '../d
import { getStore, getDeviceId } from '../store';
import { getBackendClient } from '../api/backend-client';
import type { AgentCatalogItem, LocalAgentConfig, CloudAgentConfig, AgentRunLog, JourneyMessage, BackupMetadata } from '../../shared/api-types';
import { orchestrate, dailyBrief } from '../ai/orchestrator';
import { orchestrate, orchestrateFloating, dailyBrief, getCachedBrief, invalidateBriefCache } from '../ai/orchestrator';
import { upsertNoteEmbedding } from '../db/vectordb';
import { getAuthManager, AuthError } from '../auth/auth-manager';
import { getBackupManager } from '../backup/backup-manager';
@@ -15,6 +15,14 @@ import type { TRPCContext } from '../ipc';
const t = initTRPC.context<TRPCContext>().create();
/** Returns true if the given Unix-ms timestamp falls on today's calendar date. */
function isDueToday(ts: number | null | undefined): boolean {
if (!ts) return false;
const d = new Date(ts);
const now = new Date();
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
}
const router = t.router;
const publicProcedure = t.procedure;
@@ -292,6 +300,7 @@ const tasksRouter = router({
isApproved: input.isApproved ?? 1,
createdAt: now,
}).run();
if (isDueToday(input.dueDate)) invalidateBriefCache();
return { id };
}),
@@ -329,6 +338,7 @@ const tasksRouter = router({
if (Object.keys(set).length > 0) {
getDb().update(tasks).set(set).where(eq(tasks.id, input.id)).run();
}
if (isDueToday(input.dueDate)) invalidateBriefCache();
return null;
}),
@@ -392,6 +402,7 @@ const checkpointsRouter = router({
isApproved: input.isApproved ?? 0,
createdAt: now,
}).run();
if (isDueToday(input.date)) invalidateBriefCache();
return { id };
}),
@@ -410,6 +421,7 @@ const checkpointsRouter = router({
if (Object.keys(set).length > 0) {
getDb().update(checkpoints).set(set).where(eq(checkpoints.id, input.id)).run();
}
if (isDueToday(input.date)) invalidateBriefCache();
return null;
}),
@@ -551,25 +563,31 @@ const settingsRouter = router({
});
const aiRouter = router({
/**
* Chat mutation — local orchestration.
* The inline input schema mirrors `ChatRequest` from `@shared/api-types`.
* Will be replaced with the shared schema in Step 3.2.
*/
chat: publicProcedure
.input(z.object({
message: z.string(),
context: z.object({
type: z.enum(['global', 'project']),
projectId: z.string().optional(),
uiContext: z.string().optional(),
}),
conversationHistory: z.array(z.object({
role: z.enum(['user', 'assistant', 'system']),
content: z.string(),
})).optional(),
mode: z.enum(['home', 'floating']).optional(),
scope: z.object({
type: z.enum(['task', 'project', 'note', 'checkpoint']),
id: z.string().optional(),
}).optional(),
}))
.mutation(async ({ input, ctx }) => {
try {
if (input.mode === 'floating' && input.scope) {
return await orchestrateFloating({
message: input.message,
scope: input.scope,
sender: ctx.sender,
});
}
return await orchestrate({
message: input.message,
context: input.context,
conversationHistory: input.conversationHistory,
sender: ctx.sender,
});
} catch (err) {
@@ -577,6 +595,9 @@ const aiRouter = router({
return { response: '', error: msg };
}
}),
getBrief: publicProcedure
.query(() => getCachedBrief()),
dailyBrief: publicProcedure
.mutation(async ({ ctx }) => {
try {

View File

@@ -18,6 +18,8 @@ interface AppSettings {
backupIntervalHours: number;
/** Unix epoch ms of the last successful backup upload. Null if none. */
lastBackupAt: number | null;
/** Cached daily brief — regenerated once per day or when relevant data changes. */
dailyBriefCache: { content: string; date: string } | null;
}
let _store: Store<AppSettings> | null = null;
@@ -34,6 +36,7 @@ export function getStore(): Store<AppSettings> {
backupEnabled: false,
backupIntervalHours: 24,
lastBackupAt: null,
dailyBriefCache: null,
},
});
}

View File

@@ -20,24 +20,30 @@ contextBridge.exposeInMainWorld('electronTRPC', {
});
const AI_STREAM_CHANNEL = 'ai:stream';
const AI_ACTION_CHANNEL = 'ai:action';
// V3 stream event — discriminated union of all frame types the renderer can receive.
type V3StreamEvent =
| { type: 'stream_start'; requestId: string }
| { type: 'stream_text'; requestId: string; chunk: string }
| { type: 'stream_block'; requestId: string; blockType: 'chart' | 'entity_ref' | 'table' | 'timeline'; data: Record<string, unknown> }
| { type: 'stream_end'; requestId: string; mutations?: Record<string, unknown> }
| { type: 'floating_domain'; requestId: string; domain: 'tasks' | 'notes' | 'checkpoints' | 'projects' };
contextBridge.exposeInMainWorld('electronAI', {
/** Subscribe to AI streaming chunks. Returns an unsubscribe function. */
onStreamChunk: (cb: (data: { token: string; done: boolean }) => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: { token: string; done: boolean }) => cb(data);
/** Subscribe to v3 AI stream events. Returns an unsubscribe function. */
onStreamEvent: (cb: (data: V3StreamEvent) => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: V3StreamEvent) => cb(data);
ipcRenderer.on(AI_STREAM_CHANNEL, handler);
return () => {
ipcRenderer.removeListener(AI_STREAM_CHANNEL, handler);
};
},
/** Subscribe to AI action events (task created, suggestions, etc.). Returns unsubscribe. */
onAction: (cb: (data: { type: string; taskId?: string; count?: number }) => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: { type: string; taskId?: string; count?: number }) => cb(data);
ipcRenderer.on(AI_ACTION_CHANNEL, handler);
/** Subscribe to background brief-updated push events. Returns an unsubscribe function. */
onBriefUpdated: (cb: (content: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, content: string) => cb(content);
ipcRenderer.on('ai:brief-updated', handler);
return () => {
ipcRenderer.removeListener(AI_ACTION_CHANNEL, handler);
ipcRenderer.removeListener('ai:brief-updated', handler);
};
},
});

View File

@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { GradualBlur } from '@/components/ui/gradual-blur';
import { BlockRenderer } from './blocks';
/** Fluid font size for chat messages — scales with viewport width */
const CHAT_FONT = 'clamp(1.125rem, 1.4vw, 1.375rem)';
@@ -43,6 +44,16 @@ const fadeUp = {
},
};
/**
* Module-level brief state — survives component remounts (navigation away and back).
* `content` is the text to display immediately on remount without waiting for the query.
* `streamFired` prevents re-streaming across remounts.
*/
const briefModule = {
content: null as string | null,
streamFired: false,
};
interface AIChatPanelProps {
isHomePage?: boolean;
}
@@ -50,6 +61,7 @@ interface AIChatPanelProps {
export function AIChatPanel({
isHomePage,
}: AIChatPanelProps) {
const utils = trpc.useUtils();
const authStatusQuery = trpc.auth.status.useQuery();
// Home-specific queries
@@ -66,14 +78,13 @@ export function AIChatPanel({
setInput,
isStreaming,
streamingContent,
streamingBlocks,
handleSend: chatHandleSend,
} = useAIChat(chatContext);
// Daily brief state (home page only)
const [dailyBrief, setDailyBrief] = useState<string | null>(null);
const [briefLoading, setBriefLoading] = useState(false);
// Daily brief state — initialized from module-level cache so remounts are instant.
const [dailyBrief, setDailyBrief] = useState<string | null>(() => briefModule.content);
const briefContentRef = useRef('');
const hasFiredBrief = useRef(false);
const [briefExpanded, setBriefExpanded] = useState(false);
const [briefDismissed, setBriefDismissed] = useState(false);
@@ -88,6 +99,15 @@ export function AIChatPanel({
const pendingScrollRef = useRef(false);
const briefMutation = trpc.ai.dailyBrief.useMutation();
const briefLoading = briefMutation.isPending;
// Fetch cached brief from main process — null means stale/missing (needs regen).
const cachedBriefQuery = trpc.ai.getBrief.useQuery(undefined, {
enabled: !!isHomePage && !!authStatusQuery.data?.authenticated,
// Don't auto-refetch; we'll invalidate manually on push events.
staleTime: Infinity,
refetchOnWindowFocus: false,
});
// When the user message appears in the list, set the placeholder and scroll it to the top
useEffect(() => {
@@ -120,44 +140,70 @@ export function AIChatPanel({
return () => observer.disconnect();
}, [isStreaming, streamingEl]);
// Auto-fire daily brief on home page
// Sync query result into module cache so remounts get it instantly.
useEffect(() => {
if (!isHomePage || hasFiredBrief.current || !authStatusQuery.data?.authenticated) return;
hasFiredBrief.current = true;
setBriefLoading(true);
const cached = cachedBriefQuery.data;
if (!cached) return;
briefModule.content = cached;
setDailyBrief(cached);
}, [cachedBriefQuery.data]);
const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => {
if (done) {
setDailyBrief(briefContentRef.current);
setBriefLoading(false);
unsubscribe();
// Stream a fresh brief when cache is empty (once per app session).
useEffect(() => {
if (!isHomePage || !authStatusQuery.data?.authenticated) return;
// Already have content (from module cache or query) — nothing to do.
if (briefModule.content) return;
// Wait for query to finish before deciding whether to stream.
if (cachedBriefQuery.isLoading) return;
// Query returned content — handled by the sync effect above.
if (cachedBriefQuery.data) return;
// Guard against firing twice.
if (briefModule.streamFired) return;
briefModule.streamFired = true;
briefContentRef.current = '';
let briefRequestId: string | null = null;
let unsubscribe: (() => void) | null = window.electronAI.onStreamEvent((event) => {
if (event.type === 'stream_start') {
briefRequestId = event.requestId;
return;
}
briefContentRef.current += token;
setDailyBrief(briefContentRef.current);
if (briefRequestId !== null && event.requestId !== briefRequestId) return;
if (event.type === 'stream_text') {
briefContentRef.current += event.chunk;
briefModule.content = briefContentRef.current;
setDailyBrief(briefContentRef.current);
} else if (event.type === 'stream_end') {
const final = briefContentRef.current || null;
briefModule.content = final;
setDailyBrief(final);
unsubscribe?.();
unsubscribe = null;
// Invalidate so the persisted cache is loaded on next app launch / reload.
void utils.ai.getBrief.invalidate();
}
});
briefMutation.mutate(undefined, {
onSuccess: (data) => {
if (data.error) {
unsubscribe();
setDailyBrief(null);
setBriefLoading(false);
}
},
onError: () => {
unsubscribe();
setDailyBrief(null);
setBriefLoading(false);
unsubscribe?.();
unsubscribe = null;
briefModule.streamFired = false; // allow retry on error
},
});
}, [isHomePage, authStatusQuery.data?.authenticated]); // briefMutation excluded — only fire once
return () => {
unsubscribe?.();
unsubscribe = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isHomePage, authStatusQuery.data?.authenticated, cachedBriefQuery.isLoading, cachedBriefQuery.data]);
const handleSend = useCallback(() => {
if (briefLoading) return;
pendingScrollRef.current = true;
chatHandleSend();
}, [briefLoading, chatHandleSend]);
}, [chatHandleSend]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -313,18 +359,14 @@ export function AIChatPanel({
</div>
) : dailyBrief ? (
<ChatMarkdown content={dailyBrief} size="lg" />
) : (
<p className="text-muted-foreground" style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}>
Your daily brief will appear here.
</p>
)}
) : null}
</motion.div>
{/* Input + suggestion links */}
<motion.div variants={fadeUp} className="max-w-3xl">
<ChatInput
input={input}
isStreaming={isStreaming || briefLoading}
isStreaming={isStreaming}
onInputChange={setInput}
onKeyDown={handleKeyDown}
onSend={handleSend}
@@ -389,8 +431,11 @@ export function AIChatPanel({
<Sparkles size={16} className="text-foreground" />
<span style={{ fontSize: CHAT_FONT }} className="font-semibold">Adiuva</span>
</div>
<div className="pl-[22px]">
<div className="pl-[22px] flex flex-col gap-3">
<ChatMarkdown content={msg.content} fontSize={CHAT_FONT} />
{msg.blocks.map((block) => (
<BlockRenderer key={block.id} block={block} />
))}
</div>
</div>
);
@@ -404,8 +449,11 @@ export function AIChatPanel({
<span style={{ fontSize: CHAT_FONT }} className="font-semibold">Adiuva</span>
</div>
{streamingContent ? (
<div className="pl-[22px]">
<div className="pl-[22px] flex flex-col gap-3">
<ChatMarkdown content={streamingContent} fontSize={CHAT_FONT} />
{streamingBlocks.map((block) => (
<BlockRenderer key={block.id} block={block} />
))}
</div>
) : (
<div className="space-y-2 pl-[22px]">
@@ -441,7 +489,7 @@ export function AIChatPanel({
<div className="relative pointer-events-auto mx-auto max-w-3xl">
<ChatInput
input={input}
isStreaming={isStreaming || briefLoading}
isStreaming={isStreaming}
onInputChange={setInput}
onKeyDown={handleKeyDown}
onSend={handleSend}

View File

@@ -13,23 +13,17 @@ import {
import { useAIChat, type UIChatContext } from '@/hooks/useAIChat';
import { ChatMarkdown } from '@/components/ai/AIChatPanel';
import { Skeleton } from '@/components/ui/skeleton';
import { trpc } from '@/lib/trpc';
/** Map section IDs to their routes for cross-page navigation */
const SECTION_ROUTES: Record<string, string> = {
'project-summary': 'project',
'project-timeline': 'project',
'project-tasks': 'project',
'project-notes': 'project',
'tasks-overview': '/tasks',
'tasks-list': '/tasks',
'timeline-chart': '/timeline',
'note-editor': 'note',
/** Map floating_domain signals to routes for background navigation */
const DOMAIN_ROUTES: Record<string, string> = {
tasks: '/tasks',
notes: '/notes',
checkpoints: '/timeline',
projects: '/projects',
};
function FloatingChatInner() {
const { state, sections, close, setMorphTarget, moveToSection, updatePosition, setPendingSection } = useFloatingChat();
const utils = trpc.useUtils();
const { state, sections, close, updatePosition, setPendingSection } = useFloatingChat();
const navigate = useNavigate();
const routerState = useRouterState();
const prevPathRef = useRef(routerState.location.pathname);
@@ -37,41 +31,42 @@ function FloatingChatInner() {
// Active section lookup
const activeSection = sections.get(state.activeSectionId ?? '');
// Chat context derived from active section
const chatContext = useMemo<UIChatContext>(
() => ({
type: activeSection?.projectId ? 'project' : 'global',
// Chat context — floating mode with scope derived from active section
const chatContext = useMemo<UIChatContext>(() => {
const scope = activeSection
? {
type: (activeSection.label?.toLowerCase().includes('task')
? 'task'
: activeSection.label?.toLowerCase().includes('note')
? 'note'
: activeSection.label?.toLowerCase().includes('checkpoint') || activeSection.label?.toLowerCase().includes('timeline')
? 'checkpoint'
: 'project') as 'task' | 'project' | 'note' | 'checkpoint',
id: activeSection.projectId,
}
: undefined;
return {
type: 'floating' as const,
projectId: activeSection?.projectId,
uiContext: activeSection?.label,
}),
[activeSection?.projectId, activeSection?.label],
);
scope,
};
}, [activeSection?.projectId, activeSection?.label]);
// Handle [SECTION:xxx] tags from AI responses
const handleSectionTag = useCallback((sectionId: string) => {
// Same-page: section is already registered
const targetSection = sections.get(sectionId);
if (targetSection) {
moveToSection(sectionId);
targetSection.ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
return;
}
// Handle floating_domain signals — navigate in background
const handleDomainSignal = useCallback(
(domain: 'tasks' | 'notes' | 'checkpoints' | 'projects') => {
const route = DOMAIN_ROUTES[domain];
if (!route) return;
// Cross-page: section not registered, navigate to its route
const route = SECTION_ROUTES[sectionId];
if (!route) return;
const currentPath = routerState.location.pathname;
if (currentPath === route) return;
setPendingSection({ sectionId });
if (route === 'project' && state.projectId) {
// Navigate to the project page (stay on same project)
// Project sections re-register on mount and pendingSection will auto-open
void navigate({ to: '/projects', search: { projectId: state.projectId } });
} else if (route.startsWith('/')) {
setPendingSection({ sectionId: domain });
void navigate({ to: route });
}
// 'note' type requires noteId — skip cross-page for now
}, [sections, moveToSection, setPendingSection, state.projectId, navigate]);
},
[routerState.location.pathname, navigate, setPendingSection],
);
const {
messages,
@@ -81,7 +76,7 @@ function FloatingChatInner() {
streamingContent,
handleSend,
clearMessages,
} = useAIChat(chatContext, { onSectionTag: handleSectionTag });
} = useAIChat(chatContext, { onDomainSignal: handleDomainSignal });
const containerRef = useRef<HTMLDivElement>(null);
@@ -119,31 +114,6 @@ function FloatingChatInner() {
prevOpenRef.current = state.isOpen;
}, [state.isOpen, clearMessages]);
// ---- AI action: morph into newly-created task ----
useEffect(() => {
if (!state.isOpen) return;
const unsubscribe = window.electronAI.onAction((action) => {
if (action.type === 'task_created' && action.taskId) {
// Invalidate task queries so the new TaskRow renders
void utils.tasks.list.invalidate();
// Set the morph target layoutId
setMorphTarget(`task-morph-${action.taskId}`);
// Wait for the TaskRow to render, then close (triggering FLIP)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
close();
});
});
}
});
return unsubscribe;
}, [state.isOpen, utils, setMorphTarget, close]);
// ---- Window resize: keep within bounds ----
useEffect(() => {

View File

@@ -0,0 +1,161 @@
import { useMemo } from 'react';
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Line,
LineChart,
Pie,
PieChart,
PolarAngleAxis,
PolarGrid,
Radar,
RadarChart,
RadialBar,
RadialBarChart,
XAxis,
} from 'recharts';
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
import type { ChartBlockData } from '../../../../../shared/api-types';
export function ChatChartBlock({ data: blockData }: { data: ChartBlockData }) {
const { chartType, title, data, config } = blockData;
const chartConfig = useMemo(() => {
const cfg: ChartConfig = {};
for (const [key, val] of Object.entries(config)) {
cfg[key] = { label: val.label, color: val.color };
}
return cfg;
}, [config]);
const dataKeys = useMemo(() => Object.keys(config), [config]);
return (
<div className="rounded-lg border border-border bg-card p-4">
{title && (
<p className="mb-3 text-sm font-medium">{title}</p>
)}
<ChartContainer config={chartConfig} className="max-h-[240px] w-full">
{renderChart(chartType, data, dataKeys)}
</ChartContainer>
</div>
);
}
function renderChart(
chartType: ChartBlockData['chartType'],
data: Record<string, unknown>[],
dataKeys: string[],
) {
switch (chartType) {
case 'area':
return (
<AreaChart accessibilityLayer data={data}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="name"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<ChartTooltip content={<ChartTooltipContent />} />
{dataKeys.map((key) => (
<Area
key={key}
type="monotone"
dataKey={key}
fill={`var(--color-${key})`}
stroke={`var(--color-${key})`}
fillOpacity={0.3}
/>
))}
</AreaChart>
);
case 'bar':
return (
<BarChart accessibilityLayer data={data}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="name"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<ChartTooltip content={<ChartTooltipContent />} />
{dataKeys.map((key) => (
<Bar key={key} dataKey={key} fill={`var(--color-${key})`} radius={4} />
))}
</BarChart>
);
case 'line':
return (
<LineChart accessibilityLayer data={data}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="name"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<ChartTooltip content={<ChartTooltipContent />} />
{dataKeys.map((key) => (
<Line
key={key}
type="monotone"
dataKey={key}
stroke={`var(--color-${key})`}
strokeWidth={2}
dot={false}
/>
))}
</LineChart>
);
case 'pie':
return (
<PieChart>
<ChartTooltip content={<ChartTooltipContent />} />
<Pie
data={data}
dataKey={dataKeys[0] ?? 'value'}
nameKey="name"
innerRadius="40%"
outerRadius="70%"
/>
</PieChart>
);
case 'radar':
return (
<RadarChart data={data}>
<PolarGrid />
<PolarAngleAxis dataKey="name" />
<ChartTooltip content={<ChartTooltipContent />} />
{dataKeys.map((key) => (
<Radar
key={key}
dataKey={key}
fill={`var(--color-${key})`}
fillOpacity={0.3}
stroke={`var(--color-${key})`}
/>
))}
</RadarChart>
);
case 'radial':
return (
<RadialBarChart data={data} innerRadius="30%" outerRadius="90%">
<ChartTooltip content={<ChartTooltipContent />} />
{dataKeys.map((key) => (
<RadialBar key={key} dataKey={key} fill={`var(--color-${key})`} />
))}
</RadialBarChart>
);
}
}

View File

@@ -0,0 +1,107 @@
import { FileText, FolderOpen, Sparkles } from 'lucide-react';
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
import type { EntityRefBlockData } from '../../../../../shared/api-types';
// eslint-disable-next-line @typescript-eslint/no-empty-function
function noop() {}
export function ChatEntityBlock({ data }: { data: EntityRefBlockData }) {
const { entity, items } = data;
if (!items.length) return null;
return (
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card p-3">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{entity === 'task' ? 'Tasks' : entity === 'project' ? 'Projects' : entity === 'note' ? 'Notes' : 'Checkpoints'}
</p>
<div className="flex flex-col gap-1.5">
{items.map((item, i) => {
const raw = item as Record<string, unknown>;
const id = String(raw.id ?? i);
switch (entity) {
case 'task':
return (
<TaskRow
key={id}
task={mapToTaskItem(raw)}
onToggle={noop}
hideBreadcrumb
/>
);
case 'project':
return (
<Item key={id} variant="outline" size="sm">
<ItemMedia variant="icon">
<FolderOpen className="h-4 w-4 text-muted-foreground" />
</ItemMedia>
<ItemContent>
<ItemTitle>{String(raw.name ?? raw.title ?? 'Untitled')}</ItemTitle>
{raw.description && (
<ItemDescription>{String(raw.description)}</ItemDescription>
)}
</ItemContent>
</Item>
);
case 'note':
return (
<Item key={id} variant="outline" size="sm">
<ItemMedia variant="icon">
<FileText className="h-4 w-4 text-muted-foreground" />
</ItemMedia>
<ItemContent>
<ItemTitle>{String(raw.title ?? 'Untitled')}</ItemTitle>
</ItemContent>
</Item>
);
case 'checkpoint':
return (
<Item
key={id}
variant="outline"
size="sm"
className={raw.isAiSuggested ? 'border-dashed' : ''}
>
<ItemMedia variant="icon">
{raw.isAiSuggested ? (
<Sparkles className="h-4 w-4 text-amber-500" />
) : (
<div className="h-2 w-2 rounded-full bg-primary" />
)}
</ItemMedia>
<ItemContent>
<ItemTitle>{String(raw.title ?? 'Untitled')}</ItemTitle>
{raw.date && (
<ItemDescription>
{new Date(Number(raw.date)).toLocaleDateString()}
</ItemDescription>
)}
</ItemContent>
</Item>
);
}
})}
</div>
</div>
);
}
function mapToTaskItem(raw: Record<string, unknown>): TaskItem {
return {
id: String(raw.id ?? ''),
projectId: raw.projectId != null ? String(raw.projectId) : null,
title: String(raw.title ?? 'Untitled'),
description: raw.description != null ? String(raw.description) : null,
status: raw.status != null ? String(raw.status) : null,
priority: raw.priority != null ? String(raw.priority) : null,
assignee: raw.assignee != null ? String(raw.assignee) : null,
dueDate: raw.dueDate != null ? Number(raw.dueDate) : null,
isAiSuggested: Number(raw.isAiSuggested ?? 0),
isApproved: Number(raw.isApproved ?? 0),
projectName: raw.projectName != null ? String(raw.projectName) : null,
clientName: raw.clientName != null ? String(raw.clientName) : null,
subClientName: raw.subClientName != null ? String(raw.subClientName) : null,
};
}

View File

@@ -0,0 +1,40 @@
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from '@/components/ui/table';
import type { TableBlockData } from '../../../../../shared/api-types';
export function ChatTableBlock({ data }: { data: TableBlockData }) {
const { headers, rows } = data;
if (!headers.length && !rows.length) return null;
return (
<div className="rounded-lg border border-border bg-card overflow-hidden">
<Table>
{headers.length > 0 && (
<TableHeader>
<TableRow>
{headers.map((h, i) => (
<TableHead key={i}>{h}</TableHead>
))}
</TableRow>
</TableHeader>
)}
<TableBody>
{rows.map((row, ri) => (
<TableRow key={ri}>
{row.map((cell, ci) => (
<TableCell key={ci}>{cell}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { format } from 'date-fns';
import type { TimelineBlockData } from '../../../../../shared/api-types';
export function ChatTimelineBlock({ data }: { data: TimelineBlockData }) {
const { checkpoints } = data;
if (!checkpoints.length) return null;
const sorted = [...checkpoints].sort((a, b) => a.date - b.date);
return (
<div className="rounded-lg border border-border bg-card p-4">
<div className="relative ml-3 border-l border-border pl-6">
{sorted.map((cp, i) => (
<div key={cp.id} className={i < sorted.length - 1 ? 'pb-5' : ''}>
{/* Dot on the timeline */}
<div className="absolute -left-[5px] mt-1.5 h-2.5 w-2.5 rounded-full bg-primary" />
<p className="text-sm font-medium leading-tight">{cp.title}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{format(new Date(cp.date), 'MMM d, yyyy')}
</p>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { motion } from 'framer-motion';
import type { StreamBlock } from '@/hooks/useAIChat';
import type {
ChartBlockData,
EntityRefBlockData,
TableBlockData,
TimelineBlockData,
} from '../../../../../shared/api-types';
import { ChatChartBlock } from './ChatChartBlock';
import { ChatEntityBlock } from './ChatEntityBlock';
import { ChatTableBlock } from './ChatTableBlock';
import { ChatTimelineBlock } from './ChatTimelineBlock';
const blockAnimation = {
initial: { opacity: 0, scale: 0.95 },
animate: { opacity: 1, scale: 1 },
transition: { type: 'spring' as const, stiffness: 400, damping: 30 },
};
export function BlockRenderer({ block }: { block: StreamBlock }) {
return (
<motion.div {...blockAnimation}>
{renderBlock(block)}
</motion.div>
);
}
function renderBlock(block: StreamBlock) {
switch (block.blockType) {
case 'chart':
return <ChatChartBlock data={block.data as unknown as ChartBlockData} />;
case 'entity_ref':
return <ChatEntityBlock data={block.data as unknown as EntityRefBlockData} />;
case 'table':
return <ChatTableBlock data={block.data as unknown as TableBlockData} />;
case 'timeline':
return <ChatTimelineBlock data={block.data as unknown as TimelineBlockData} />;
default:
return null;
}
}

View File

@@ -0,0 +1,211 @@
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field';
// ---------------------------------------------------------------------------
// Sign-in form (login-03 layout)
// ---------------------------------------------------------------------------
function SignInForm({
className,
onSwitchMode,
...props
}: React.ComponentPropsWithoutRef<'div'> & { onSwitchMode: () => void }) {
const utils = trpc.useUtils();
const loginMutation = trpc.auth.login.useMutation();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
function handleSubmit(e: React.SyntheticEvent) {
e.preventDefault();
if (!email || !password) return;
setError('');
loginMutation.mutate({ email, password }, {
onSuccess: (res) => {
if (!res.success) setError(res.error ?? 'Authentication failed');
else void utils.auth.status.invalidate();
},
onError: (err) => setError(err.message),
});
}
return (
<div className={cn('flex flex-col gap-6', className)} {...props}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Welcome back</CardTitle>
<CardDescription>Sign in to your Adiuva account</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<div className="grid gap-6">
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(''); }}
disabled={loginMutation.isPending}
autoFocus
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => { setPassword(e.target.value); setError(''); }}
disabled={loginMutation.isPending}
required
/>
</div>
{error && <p className="text-sm text-destructive -mt-2">{error}</p>}
<Button type="submit" className="w-full" disabled={loginMutation.isPending || !email || !password}>
Sign in
</Button>
</div>
<div className="text-center text-sm">
Don&apos;t have an account?{' '}
<button type="button" className="underline underline-offset-4 hover:text-primary" onClick={onSwitchMode}>
Sign up
</button>
</div>
</div>
</form>
</CardContent>
</Card>
<p className="text-balance text-center text-xs text-muted-foreground">
AI features require a connection to the Adiuva backend.
</p>
</div>
);
}
// ---------------------------------------------------------------------------
// Sign-up form (signup-03 layout)
// ---------------------------------------------------------------------------
function SignUpForm({
className,
onSwitchMode,
...props
}: React.ComponentPropsWithoutRef<'div'> & { onSwitchMode: () => void }) {
const utils = trpc.useUtils();
const registerMutation = trpc.auth.register.useMutation();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
function handleSubmit(e: React.SyntheticEvent) {
e.preventDefault();
if (!email || !password) return;
setError('');
registerMutation.mutate({ email, password }, {
onSuccess: (res) => {
if (!res.success) setError(res.error ?? 'Registration failed');
else void utils.auth.status.invalidate();
},
onError: (err) => setError(err.message),
});
}
return (
<div className={cn('flex flex-col gap-6', className)} {...props}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Create your account</CardTitle>
<CardDescription>Enter your email below to create your account</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="reg-email">Email</FieldLabel>
<Input
id="reg-email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(''); }}
disabled={registerMutation.isPending}
autoFocus
required
/>
</Field>
<Field>
<Field>
<FieldLabel htmlFor="reg-password">Password</FieldLabel>
<Input
id="reg-password"
type="password"
value={password}
onChange={(e) => { setPassword(e.target.value); setError(''); }}
disabled={registerMutation.isPending}
required
/>
</Field>
<FieldDescription>Must be at least 8 characters long.</FieldDescription>
</Field>
{error && <p className="text-sm text-destructive">{error}</p>}
<Field>
<Button type="submit" className="w-full" disabled={registerMutation.isPending || !email || !password}>
Create Account
</Button>
<FieldDescription className="text-center">
Already have an account?{' '}
<button type="button" className="underline underline-offset-4 hover:text-primary" onClick={onSwitchMode}>
Sign in
</button>
</FieldDescription>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
<FieldDescription className="px-6 text-center">
By creating an account, you agree to our terms of service.
</FieldDescription>
</div>
);
}
// ---------------------------------------------------------------------------
// Shell — logo + mode switcher
// ---------------------------------------------------------------------------
export function LoginForm() {
const [mode, setMode] = useState<'login' | 'register'>('login');
return (
<div className="flex w-full h-full flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<div className="flex items-center gap-2 self-center font-medium">
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary text-primary-foreground">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M12 2L13.5 8.5L20 10L13.5 11.5L12 18L10.5 11.5L4 10L10.5 8.5L12 2Z" fill="currentColor" />
</svg>
</div>
Adiuva
</div>
{mode === 'login' ? (
<SignInForm onSwitchMode={() => setMode('register')} />
) : (
<SignUpForm onSwitchMode={() => setMode('login')} />
)}
</div>
</div>
);
}

View File

@@ -8,7 +8,6 @@ import {
FolderKanban,
PanelLeft,
Settings,
Sparkles,
} from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { useDoubleClickAI } from '@/hooks/useDoubleClickAI';
@@ -30,6 +29,7 @@ import {
import { AIChatPanel } from '@/components/ai/AIChatPanel';
import { FloatingChatPortal } from '@/components/ai/FloatingChat';
import { FloatingChatProvider } from '@/context/FloatingChatContext';
import { LoginForm } from '@/components/auth/LoginForm';
const NAV_ITEMS = [
{ to: '/', icon: House, label: 'Home' },
@@ -45,7 +45,9 @@ interface AppShellProps {
export function AppShell({ children }: AppShellProps) {
return (
<FloatingChatProvider>
<AppShellInner>{children}</AppShellInner>
<div className="flex w-full h-full">
<AppShellInner>{children}</AppShellInner>
</div>
</FloatingChatProvider>
);
}
@@ -53,6 +55,11 @@ export function AppShell({ children }: AppShellProps) {
function AppShellInner({ children }: AppShellProps) {
useDoubleClickAI();
const authStatusQuery = trpc.auth.status.useQuery(undefined, {
staleTime: 5 * 60 * 1000, // 5 min — don't hammer the backend on every focus
retry: false,
});
const collapsedQuery = trpc.settings.getSidebarCollapsed.useQuery(undefined, {
staleTime: Infinity,
});
@@ -76,6 +83,13 @@ function AppShellInner({ children }: AppShellProps) {
const isHomePage = currentPath === '/';
// Auth gate — show login screen until we know the user is authenticated.
// While loading (data is undefined), fall through to app to avoid flicker;
// only show the login form when we have a definitive `authenticated: false`.
if (authStatusQuery.data?.authenticated === false) {
return <LoginForm />;
}
return (
<LayoutGroup>
<SidebarProvider open={open} onOpenChange={handleOpenChange}>

View File

@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
@@ -42,7 +42,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)

View File

@@ -0,0 +1,357 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium text-foreground tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@@ -0,0 +1,246 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-sm leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -2,27 +2,34 @@ import { useState, useCallback, useRef } from 'react';
import { trpc } from '@/lib/trpc';
// ---------------------------------------------------------------------------
// UI-only chat context (renderer concern — not sent to backend)
// Types
// ---------------------------------------------------------------------------
/**
* Renderer-only context describing where the user is in the UI.
* The main process uses these fields to build the enriched `ChatContext`
* (from `@shared/api-types`) before forwarding to the backend.
* Retained for call-site compatibility; mode/scope fields support v3 routing.
*/
export interface UIChatContext {
/** Scope of the conversation. */
type: 'global' | 'project';
/** Active project ID when `type === 'project'`. */
type: 'global' | 'project' | 'floating';
projectId?: string;
/** Serialised description of the current UI state (visible section, selected item, etc.). */
uiContext?: string;
/** For floating mode — the entity scope to pass to the backend. */
scope?: {
type: 'task' | 'project' | 'note' | 'checkpoint';
id?: string;
};
}
interface ChatMessage {
export interface StreamBlock {
id: string;
blockType: 'chart' | 'entity_ref' | 'table' | 'timeline';
data: Record<string, unknown>;
}
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
blocks: StreamBlock[];
error?: boolean;
}
@@ -32,27 +39,36 @@ interface UseAIChatReturn {
setInput: (v: string) => void;
isStreaming: boolean;
streamingContent: string;
streamingBlocks: StreamBlock[];
handleSend: (overrideMessage?: string, overrideContext?: UIChatContext) => void;
clearMessages: () => void;
}
interface UseAIChatOptions {
onSectionTag?: (sectionId: string) => void;
onDomainSignal?: (domain: 'tasks' | 'notes' | 'checkpoints' | 'projects') => void;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOptions): UseAIChatReturn {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const [streamingBlocks, setStreamingBlocks] = useState<StreamBlock[]>([]);
const streamingContentRef = useRef('');
const streamingBlocksRef = useRef<StreamBlock[]>([]);
const chatMutation = trpc.ai.chat.useMutation();
const clearMessages = useCallback(() => {
setMessages([]);
setStreamingContent('');
setStreamingBlocks([]);
streamingContentRef.current = '';
streamingBlocksRef.current = [];
}, []);
const handleSend = useCallback(
@@ -60,53 +76,100 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
const trimmed = (overrideMessage ?? input).trim();
if (!trimmed || isStreaming) return;
const ctx = overrideContext ?? defaultContext;
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content: trimmed,
blocks: [],
};
setMessages((prev) => [...prev, userMsg]);
if (!overrideMessage) setInput('');
setIsStreaming(true);
setStreamingContent('');
setStreamingBlocks([]);
streamingContentRef.current = '';
streamingBlocksRef.current = [];
const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => {
if (done) {
let finalContent = streamingContentRef.current;
// Capture the requestId from stream_start so we only handle events
// for this specific chat request (avoids cross-contamination with
// other concurrent streams like the daily brief).
let activeRequestId: string | null = null;
// Parse and strip [SECTION:xxx] tag from AI response
const sectionMatch = finalContent.match(/^\[SECTION:([\w-]+)\]\s*/);
if (sectionMatch) {
finalContent = finalContent.slice(sectionMatch[0].length);
options?.onSectionTag?.(sectionMatch[1]!);
}
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: finalContent },
]);
setStreamingContent('');
streamingContentRef.current = '';
setIsStreaming(false);
unsubscribe();
const unsubscribe = window.electronAI.onStreamEvent((event) => {
// Latch the requestId on first stream_start
if (event.type === 'stream_start') {
activeRequestId = event.requestId;
return;
}
streamingContentRef.current += token;
setStreamingContent(streamingContentRef.current);
// Once we have a requestId, ignore events from other streams
if (activeRequestId !== null && event.requestId !== activeRequestId) {
return;
}
switch (event.type) {
case 'stream_text':
streamingContentRef.current += event.chunk;
setStreamingContent(streamingContentRef.current);
break;
case 'stream_block': {
const block: StreamBlock = {
id: crypto.randomUUID(),
blockType: event.blockType,
data: event.data,
};
streamingBlocksRef.current = [...streamingBlocksRef.current, block];
setStreamingBlocks([...streamingBlocksRef.current]);
break;
}
case 'stream_end': {
const finalContent = streamingContentRef.current;
const finalBlocks = streamingBlocksRef.current;
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: 'assistant',
content: finalContent,
blocks: finalBlocks,
},
]);
setStreamingContent('');
setStreamingBlocks([]);
streamingContentRef.current = '';
streamingBlocksRef.current = [];
setIsStreaming(false);
unsubscribe();
break;
}
case 'floating_domain':
options?.onDomainSignal?.(event.domain);
break;
}
});
const ctx = overrideContext ?? defaultContext;
// Build conversation history from current messages (before this send)
const conversationHistory = messages.map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.content,
}));
const isFloating = ctx.type === 'floating';
chatMutation.mutate(
{
message: trimmed,
context: {
type: ctx.type,
...(ctx.type === 'project' && ctx.projectId ? { projectId: ctx.projectId } : {}),
...(ctx.uiContext ? { uiContext: ctx.uiContext } : {}),
},
conversationHistory,
...(isFloating && ctx.scope
? { mode: 'floating' as const, scope: ctx.scope }
: {}),
},
{
onSuccess: (data) => {
@@ -114,10 +177,35 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
unsubscribe();
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: data.error!, error: true },
{ id: crypto.randomUUID(), role: 'assistant', content: data.error!, blocks: [], error: true },
]);
setStreamingContent('');
setStreamingBlocks([]);
streamingContentRef.current = '';
streamingBlocksRef.current = [];
setIsStreaming(false);
} else {
// Safety fallback: the tRPC response always arrives after the
// orchestrator finishes (which is after stream_end was sent).
// If the IPC stream_end was missed (e.g. listener removed by
// React StrictMode, or requestId mismatch), this ensures
// isStreaming is reset.
unsubscribe();
if (streamingContentRef.current || streamingBlocksRef.current.length) {
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: 'assistant',
content: streamingContentRef.current,
blocks: streamingBlocksRef.current,
},
]);
}
setStreamingContent('');
setStreamingBlocks([]);
streamingContentRef.current = '';
streamingBlocksRef.current = [];
setIsStreaming(false);
}
},
@@ -129,17 +217,20 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
id: crypto.randomUUID(),
role: 'assistant',
content: err.message || 'An unexpected error occurred.',
blocks: [],
error: true,
},
]);
setStreamingContent('');
setStreamingBlocks([]);
streamingContentRef.current = '';
streamingBlocksRef.current = [];
setIsStreaming(false);
},
},
);
},
[input, isStreaming, defaultContext, chatMutation],
[input, isStreaming, defaultContext, chatMutation, messages, options],
);
return {
@@ -148,6 +239,7 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
setInput,
isStreaming,
streamingContent,
streamingBlocks,
handleSend,
clearMessages,
};

View File

@@ -13,9 +13,16 @@ interface ElectronTRPC {
onMessage: (cb: (data: unknown) => void) => (() => void) | void;
}
type V3StreamEvent =
| { type: 'stream_start'; requestId: string }
| { type: 'stream_text'; requestId: string; chunk: string }
| { type: 'stream_block'; requestId: string; blockType: 'chart' | 'entity_ref' | 'table' | 'timeline'; data: Record<string, unknown> }
| { type: 'stream_end'; requestId: string; mutations?: Record<string, unknown> }
| { type: 'floating_domain'; requestId: string; domain: 'tasks' | 'notes' | 'checkpoints' | 'projects' };
interface ElectronAI {
onStreamChunk: (cb: (data: { token: string; done: boolean }) => void) => () => void;
onAction: (cb: (data: { type: string; taskId?: string; count?: number }) => void) => () => void;
onStreamEvent: (cb: (data: V3StreamEvent) => void) => () => void;
onBriefUpdated: (cb: (content: string) => void) => () => void;
}
interface ElectronDialog {

View File

@@ -4,7 +4,6 @@ import { z } from 'zod';
import {
Settings,
User,
LogIn,
LogOut,
Bot,
Palette,
@@ -232,19 +231,13 @@ function GeneralSection() {
// ---------------------------------------------------------------------------
function AccountSection() {
const authStatusQuery = trpc.auth.status.useQuery();
const authStatusQuery = trpc.auth.status.useQuery(undefined, { staleTime: 5 * 60 * 1000 });
const backendUrlQuery = trpc.auth.getBackendUrl.useQuery();
const loginMutation = trpc.auth.login.useMutation();
const logoutMutation = trpc.auth.logout.useMutation();
const registerMutation = trpc.auth.register.useMutation();
const setBackendUrlMutation = trpc.auth.setBackendUrl.useMutation();
const utils = trpc.useUtils();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [mode, setMode] = useState<'login' | 'register'>('login');
const [authError, setAuthError] = useState('');
const [backendUrl, setBackendUrl] = useState('');
const [urlSaved, setUrlSaved] = useState(false);
@@ -252,23 +245,6 @@ function AccountSection() {
if (backendUrlQuery.data) setBackendUrl(backendUrlQuery.data);
}, [backendUrlQuery.data]);
const isAuthenticated = authStatusQuery.data?.authenticated;
function handleAuth() {
setAuthError('');
const mutation = mode === 'login' ? loginMutation : registerMutation;
mutation.mutate({ email, password }, {
onSuccess: (res) => {
if (!res.success) {
setAuthError(res.error ?? 'Failed');
} else {
void utils.auth.status.invalidate();
}
},
onError: (err) => setAuthError(err.message),
});
}
function handleLogout() {
logoutMutation.mutate(undefined, {
onSuccess: () => void utils.auth.status.invalidate(),
@@ -299,66 +275,26 @@ function AccountSection() {
</div>
</SettingsCard>
{isAuthenticated ? (
<SettingsCard title="Account" description="Manage your account and subscription.">
<div className="flex flex-col gap-4">
{authStatusQuery.data?.profile && (
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">{authStatusQuery.data.profile.email}</p>
<Badge variant="outline" className="w-fit capitalize">{authStatusQuery.data.profile.tier}</Badge>
</div>
)}
<Button
variant="outline"
size="sm"
className="w-fit"
onClick={handleLogout}
disabled={logoutMutation.isPending}
>
<LogOut className="size-4 mr-2" />
Sign out
</Button>
</div>
</SettingsCard>
) : (
<SettingsCard
title={mode === 'login' ? 'Sign in' : 'Create account'}
description="Sign in to enable AI features, agents, and cloud sync."
>
<div className="flex flex-col gap-3">
<Input
placeholder="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Input
placeholder="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAuth()}
/>
{authError && <p className="text-xs text-destructive">{authError}</p>}
<div className="flex items-center gap-3">
<Button
size="sm"
onClick={handleAuth}
disabled={loginMutation.isPending || registerMutation.isPending}
>
<LogIn className="size-4 mr-2" />
{mode === 'login' ? 'Sign in' : 'Create account'}
</Button>
<button
onClick={() => { setMode(m => m === 'login' ? 'register' : 'login'); setAuthError(''); }}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{mode === 'login' ? 'No account? Register' : 'Have an account? Sign in'}
</button>
<SettingsCard title="Account" description="Manage your account and subscription.">
<div className="flex flex-col gap-4">
{authStatusQuery.data?.profile && (
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">{authStatusQuery.data.profile.email}</p>
<Badge variant="outline" className="w-fit capitalize">{authStatusQuery.data.profile.tier}</Badge>
</div>
</div>
</SettingsCard>
)}
)}
<Button
variant="outline"
size="sm"
className="w-fit"
onClick={handleLogout}
disabled={logoutMutation.isPending}
>
<LogOut className="size-4 mr-2" />
Sign out
</Button>
</div>
</SettingsCard>
</>
);
}

View File

@@ -30,48 +30,6 @@ export const UserProfileSchema = z.object({
});
export type UserProfile = z.infer<typeof UserProfileSchema>;
// ---------------------------------------------------------------------------
// Chat — Backend Context
// ---------------------------------------------------------------------------
/**
* Chat context sent to the backend via WebSocket.
*
* Matches the backend `ChatContext` Pydantic model (flat structure).
* UI-only fields (`type`, `projectId`, `uiContext`) live in `UIChatContext`
* in the renderer — they are NOT sent to the backend.
*/
export const ChatContextSchema = z.object({
/** User profile snapshot. */
userProfile: UserProfileSchema.optional(),
/** Relevant document snippets retrieved from the vector store. */
relevantDocuments: z.array(z.string()).optional(),
/** Recent tasks for additional context. */
recentTasks: z
.array(z.object({ id: z.string(), title: z.string(), status: z.string() }))
.optional(),
/** Previous messages in the conversation. */
conversationHistory: z
.array(z.object({ role: z.enum(['user', 'assistant', 'system']), content: z.string() }))
.optional(),
});
export type ChatContext = z.infer<typeof ChatContextSchema>;
// ---------------------------------------------------------------------------
// Chat Request / Response
// ---------------------------------------------------------------------------
export const ChatRequestSchema = z.object({
message: z.string(),
context: ChatContextSchema,
});
export type ChatRequest = z.infer<typeof ChatRequestSchema>;
export const ChatResponseSchema = z.object({
response: z.string(),
});
export type ChatResponse = z.infer<typeof ChatResponseSchema>;
// ---------------------------------------------------------------------------
// WebSocket Frames — Bidirectional Protocol
// ---------------------------------------------------------------------------
@@ -90,13 +48,6 @@ export type ToolCallAction = z.infer<typeof ToolCallActionSchema>;
// --- Client → Server frames ------------------------------------------------
export const WsChatRequestSchema = z.object({
type: z.literal('chat_request'),
message: z.string(),
context: ChatContextSchema,
});
export type WsChatRequest = z.infer<typeof WsChatRequestSchema>;
export const WsToolResultSchema = z.object({
type: z.literal('tool_result'),
id: z.string(),
@@ -148,23 +99,39 @@ export const WsDeviceHelloSchema = z.object({
});
export type WsDeviceHello = z.infer<typeof WsDeviceHelloSchema>;
// --- V3 Chat frames — Client → Server ----------------------------------------
export const WsHomeRequestSchema = z.object({
type: z.literal('home_request'),
message: z.string(),
conversationHistory: z
.array(z.object({ role: z.enum(['user', 'assistant', 'system']), content: z.string() }))
.optional(),
});
export type WsHomeRequest = z.infer<typeof WsHomeRequestSchema>;
export const WsFloatingRequestSchema = z.object({
type: z.literal('floating_request'),
message: z.string(),
scope: z.object({
type: z.enum(['task', 'project', 'note', 'checkpoint']),
id: z.string().optional(),
}),
});
export type WsFloatingRequest = z.infer<typeof WsFloatingRequestSchema>;
export const WsClientFrameSchema = z.discriminatedUnion('type', [
WsChatRequestSchema,
WsToolResultSchema,
WsAgentDataSchema,
WsAgentCompleteSchema,
WsDeviceHelloSchema,
WsHomeRequestSchema,
WsFloatingRequestSchema,
]);
export type WsClientFrame = z.infer<typeof WsClientFrameSchema>;
// --- Server → Client frames ------------------------------------------------
export const WsTextChunkSchema = z.object({
type: z.literal('text_chunk'),
text: z.string(),
});
export type WsTextChunk = z.infer<typeof WsTextChunkSchema>;
export const WsToolCallSchema = z.object({
type: z.literal('tool_call'),
id: z.string(),
@@ -177,12 +144,6 @@ export const WsToolCallSchema = z.object({
});
export type WsToolCall = z.infer<typeof WsToolCallSchema>;
export const WsFinalSchema = z.object({
type: z.literal('final'),
response: z.string(),
});
export type WsFinal = z.infer<typeof WsFinalSchema>;
export const WsPingSchema = z.object({
type: z.literal('ping'),
});
@@ -208,12 +169,78 @@ export const WsAgentRunSchema = z.object({
});
export type WsAgentRun = z.infer<typeof WsAgentRunSchema>;
// --- V3 Chat frames — Server → Client ----------------------------------------
export const WsStreamStartSchema = z.object({
type: z.literal('stream_start'),
requestId: z.string(),
});
export type WsStreamStart = z.infer<typeof WsStreamStartSchema>;
export const WsStreamTextSchema = z.object({
type: z.literal('stream_text'),
requestId: z.string(),
chunk: z.string(),
});
export type WsStreamText = z.infer<typeof WsStreamTextSchema>;
export const WsStreamBlockSchema = z.object({
type: z.literal('stream_block'),
requestId: z.string(),
blockType: z.enum(['chart', 'entity_ref', 'table', 'timeline']),
data: z.record(z.string(), z.unknown()),
});
export type WsStreamBlock = z.infer<typeof WsStreamBlockSchema>;
export const WsStreamEndSchema = z.object({
type: z.literal('stream_end'),
requestId: z.string(),
mutations: z.union([
z.record(z.string(), z.unknown()),
z.array(z.unknown()),
]).optional(),
});
export type WsStreamEnd = z.infer<typeof WsStreamEndSchema>;
export const WsFloatingDomainSchema = z.object({
type: z.literal('floating_domain'),
requestId: z.string(),
domain: z.enum(['tasks', 'notes', 'checkpoints', 'projects']),
});
export type WsFloatingDomain = z.infer<typeof WsFloatingDomainSchema>;
// --- V3 Block data interfaces ------------------------------------------------
export interface ChartBlockData {
chartType: 'area' | 'bar' | 'line' | 'pie' | 'radar' | 'radial';
title: string;
data: Record<string, unknown>[];
config: Record<string, { label: string; color: string }>;
}
export interface EntityRefBlockData {
entity: 'task' | 'project' | 'note' | 'checkpoint';
items: Record<string, unknown>[];
}
export interface TableBlockData {
headers: string[];
rows: string[][];
}
export interface TimelineBlockData {
checkpoints: { id: string; title: string; date: number }[];
}
export const WsServerFrameSchema = z.discriminatedUnion('type', [
WsTextChunkSchema,
WsToolCallSchema,
WsFinalSchema,
WsPingSchema,
WsAgentRunSchema,
WsStreamStartSchema,
WsStreamTextSchema,
WsStreamBlockSchema,
WsStreamEndSchema,
WsFloatingDomainSchema,
]);
export type WsServerFrame = z.infer<typeof WsServerFrameSchema>;