216 Commits

Author SHA1 Message Date
Roberto
1a4cfb07a5 fix(scouts): correct stale /api/v1/agents URLs and conditional scout_proposal ack
Replace all /api/v1/agents/cloud with /api/v1/scouts/cloud in scoutCloudRouter
(list, create, update, delete). Fix notes-backfill /api/v1/agents/notes/summarize
to /api/v1/scouts/notes/summarize. Move scout_proposal_ack ws.send inside the
try block so it is only sent on successful persist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 05:39:48 +02:00
Roberto
6adb13ff88 feat(scouts): Gmail OAuth UI flow
- scout.cloud.startGmailOAuth mutation: GET authorize URL, open in system browser
- scout.cloud.completeGmailOAuth mutation: POST code+state to backend callback
- handleDeepLink extended: adiuvai://scout/oauth/gmail/callback → IPC broadcast
- preload: expose onScoutGmailOAuthCallback on window.electronAI
- CloudScoutConfigPanel: Connect Gmail button + useEffect callback subscription
- CloudScoutConfig schema: add optional oauthConnected boolean field
- i18n: scouts.connectGmail + toast.scout.gmailConnected in all 5 locales

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 04:54:24 +02:00
Roberto
ff1208fd3c feat(scouts): handle scout_proposal frames and ack
On receiving a scout_proposal WS frame, persist the proposal into the
local scout_suggestions table (idempotent via onConflictDoNothing), then
send scout_proposal_ack back to the backend. Adds WsScoutProposalSchema
and WsScoutProposalAckSchema to the shared api-types contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 03:48:21 +02:00
Roberto
3d4aef7fe3 feat(db): add scout_suggestions table 2026-05-16 03:00:36 +02:00
Roberto
5cd895f04e refactor: rename CloudAgentConfig type and agentIds WS field to scout
- shared/api-types.ts: LocalAgentConfig → LocalScoutConfig,
  CloudAgentConfig → CloudScoutConfig (schema + type),
  agentIds → scoutIds in WsDeviceHelloSchema
- backend-client.ts: agentIds local var → scoutIds, wire key
  agent_ids → scout_ids via toSnakeCase
- router/index.ts: import + generic type params updated
- Settings renderer: CloudScoutConfigPanel, ScoutRow, ScoutsSection
  import updated to CloudScoutConfig
- .claude/CLAUDE.md: route path /api/v1/scouts/notes/summarize

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:50:33 +02:00
Roberto
49b1d60fca i18n: rename agents keys to scouts across all 5 languages
- settings.agents* → settings.scouts* (bucket 1)
- top-level agents namespace → scouts, all child keys renamed
  (noAgentsYet→noScoutsYet, createAgent→createScout, etc.) (bucket 2)
- toast.agent → toast.scout, values updated to say scout (bucket 3)
- Per-language translations applied consistently (en/it/es/fr/de)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:30:21 +02:00
Roberto
b258ec3de5 refactor(renderer): rename Agent components and types to Scout
- git mv AgentsSection → ScoutsSection, AgentRow → ScoutRow,
  LocalAgentConfigPanel → LocalScoutConfigPanel,
  CloudAgentConfigPanel → CloudScoutConfigPanel,
  InlineAgentCreationStepper → InlineScoutCreationStepper,
  AgentRunHistorySheet → ScoutRunHistorySheet,
  AgentRunLog → ScoutRunLog
- Update all exported function names, internal vars, toast i18n keys
  (toast.agent.* → toast.scout.*, scouts.* i18n keys)
- Replace all trpc.agent.* calls with trpc.scout.* in renderer
- Rename LocalAgentConfig → LocalScoutConfig in types.ts;
  update SectionId 'agents' → 'scouts' and SECTIONS entry
- Update settings.tsx: import ScoutsSection, render on section 'scouts'
- Update ScoutRunHistorySheet RunSummary type: agentId → scoutId
- Rewrite ScoutRunLog to use new ScoutRunSummary shape (actionCounts
  instead of deprecated itemsProcessed/itemsCreated/errors)
- Fix JourneyDialog and PromptBuilderChat: trpc.agent.journey.* → scout
- Fix import paths for shared/api-types (../../../../ → ../../../)

Typecheck: 96 errors before → 53 after (43 errors resolved, all 43
were broken trpc.agent.* refs from Task 8). Remaining 53 are
pre-existing unrelated issues.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:26:08 +02:00
Roberto
f0a18d7011 refactor(main): rename agent-scheduler/store/router symbols to scout
- Move src/main/agents/agent-scheduler.ts → src/main/scouts/scout-scheduler.ts
- Rename exported functions: startAgentScheduler/stopAgentScheduler/tickAgentScheduler → startScoutScheduler/stopScoutScheduler/tickScoutScheduler
- Update URL: /api/v1/agents/trigger → /api/v1/scouts/trigger; /api/v1/agents/can-create → /api/v1/scouts/can-create; /api/v1/agents/catalog → /api/v1/scouts/catalog
- store.ts: LocalAgentLocalConfig → LocalScoutConfig; getLocalAgents/saveLocalAgent/deleteLocalAgent/getLocalAgent → getLocalScouts/saveLocalScout/deleteLocalScout/getLocalScout; storage key localAgents → localScouts
- router/index.ts: agentRouter → scoutRouter (all sub-vars too); appRouter key agent → scout
- index.ts: update scheduler import path and start/stop call sites
- backend-client.ts: getLocalAgents → getLocalScouts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:13:07 +02:00
Roberto
9b66dc3329 refactor(db): rename agent_runs/agent_run_actions to scout_*
Rename Drizzle table definitions: agentRuns → scoutRuns,
agentRunActions → scoutRunActions. Column agentId → scoutId.
Hand-crafted migration 0007_scouts_rename.sql uses ALTER TABLE RENAME
+ CREATE/INSERT/DROP for column rename (SQLite limitation). Updated
all main-process consumers (backend-client, agent-scheduler, router).
Renderer-side type/component rename deferred to Tasks 8-9.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:06:21 +02:00
Roberto
c1b1b289c1 clear floating 2026-05-15 21:50:31 +02:00
Roberto
6aa7cb3d22 feat(contextual): empty-state copy per scope on sidebar
When the contextual sidebar opens with no messages, show a soft
hint anchored to the current page. Hint includes the entity name
for project / note views and a generic prompt for global lists.
Notes hint warns that note editing is deferred to a later release.
2026-05-15 21:16:45 +02:00
Roberto
1f60931a0f refactor(contextual): main process drops sendFloatingRequest and floating mode
ai.chat tRPC procedure now accepts mode='contextual' (or unset for home).
Orchestrator loses the floating delegation branch. Backend client method
and WsFloatingDomain shared type removed.
2026-05-15 18:43:51 +02:00
Roberto
42a457f973 refactor(contextual): drop 'floating' branch from useAIChat and useChatStream
UIChatContext is now 'global' | 'project' only. Floating domain
signal, scope field, and onDomainSignal callback removed. ChatInputBox
no longer defines floating variant. TaskBriefChat migrated to contextual mode.
2026-05-15 18:40:31 +02:00
Roberto
e6357b0d61 refactor(contextual): strip all data-ai-section attributes
Section-anchoring obsolete now that there is no floating chat.
The contextual sidebar uses scope payload, not DOM attributes.
Also removes dead sectionId/sectionLabel props from TimelineGanttView.
2026-05-15 18:38:40 +02:00
Roberto
63fc3cfa43 refactor(contextual): delete FloatingChat, FloatingChatContext, useDoubleClickAI
Replaced by ContextualChatProvider + AdiuvaTriggerButton in M4.
Pre-1.0 clean removal — no deprecation period.
2026-05-15 18:36:51 +02:00
Roberto
d50be8e7af feat(contextual): get_page_details op in drizzle-executor
Dispatches client-side snapshots for project/task/note entities and
tasks_all/projects_all/timeline_all list variants. Consumed by the
backend contextual agent's get_page_details tool over the standard
WS tool-call round-trip.

Also adds 'get_page_details' to ToolCallActionSchema enum in api-types.ts
so the Zod validator accepts the new action without rejecting frames.
2026-05-15 14:46:31 +02:00
Roberto
d6b1a86e95 feat(contextual): notes page header lives in shared AppShell header
Layout: [SidebarTrigger][|][back arrow][space][Saving?][3-dot menu][AdiuvaTriggerButton]

HeaderContext now exposes leftExtras (replaces page label slot when
set) and rightExtras (between flex-1 spacer and trigger button).
Notes route publishes both and drops its inline toolbar bar.

Separator mr-2 removed when leftExtras is set so the back arrow sits
flush against the divider. Header slot components are defined at module
scope and use mutable refs for isSaving/callbacks to avoid infinite
setState loops.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 14:41:19 +02:00
Roberto
ca669a1c5c chore(contextual): use sm trigger + scroll context fixes
- AdiuvaTriggerButton uses sm variant (40px, icon 24px)
- AppShell main scroll container: overflow-hidden + flex column so
  routes own their own scroll. Sticky ProjectTabBar still anchors
  to header boundary
- tasks.tsx wrapper gets h-full + overflow-y-auto under the new
  scroll regime
2026-05-15 07:54:26 +02:00
Roberto
ffd0e97508 fix(contextual): better contrast trigger button + use logo-mark.svg asset
- AdiuvaIcon now uses /logo/logo-mark.svg directly (served via vite
  publicDir from adiuvAI/assets/); animation is built into the asset
- Light mode: pure white surface + dusty lavender border so the
  button reads on the pinkish-white canvas; centered ambient shadow
  (was bottom-heavy)
- Dark mode: lifted surface (#1f1f22) with subtle inner highlight
- sm variant bumped to 40px (icon 18px) for sidebar new-chat/close
2026-05-15 07:53:40 +02:00
Roberto
2bc9617b14 fix(layout): restore scroll on tasks and timeline pages
AppShell's post-header wrap div used overflow-hidden to scope a
sticky context for ProjectTabBar. Pages without their own internal
scroll container (tasks, timeline) had their overflow clipped.

overflow-y-auto on the same div keeps the sticky context AND lets
tasks/timeline scroll naturally. Projects' nested scroll container
still works (sticky inside still anchors to this boundary).
2026-05-15 07:37:43 +02:00
Roberto
3aa7aa0d50 fix(projects): ProjectTabBar sticky offset + scroll-spy after AppShell header
The hero is sticky top-0 inside the scroll container; the tab bar
(also inside, immediately after) was also sticky top-0, making it
fight the hero for the same pixel. It now uses style={{ top: heroH }}
so it always sticks just below the hero regardless of compact state.

Also: heroH was captured once at observer-creation time, so it was
0 on cold mounts (hero refs null until data loads) and went stale
when the hero compacted. Replace the snapshot with a ResizeObserver
that keeps heroH in state; both the IntersectionObserver rootMargin
and scrollToSection math update automatically on every hero resize.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 07:31:22 +02:00
Roberto
8a6befd481 refactor(projects): hoist create-project button into AppShell header
Removes the projects-list sidebar's internal header strip (title + button).
A new + icon button is registered via useHeaderSlot and renders in the
AppShell header immediately after the page label, only on /projects.
Dialog state is lifted to the route root so the header trigger can open it.

Also fixes sticky-under-header bug: wraps the page content area in a new
overflow-hidden div scoped below the h-14 header, ensuring sticky elements
(ProjectTabBar) anchor to this boundary and not to SidebarInset's top.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 07:22:17 +02:00
Roberto
652a6b830d refactor(contextual): move trigger into AppShell header, shrink to sm
Trigger button now lives in the shared header strip next to the
sidebar toggle, hidden on the home route. Per-route renders are
removed; useContextualScope hook calls stay (each route still
publishes its scope payload).

ContextualChatProvider now wraps the header too (moved up one level)
so AdiuvaTriggerButton can call useContextualChat(). showHeader now
covers all non-home non-settings routes; notes/projects no longer
exclude themselves. Notes per-route header simplified to a h-9
toolbar (back + saving + overflow) since SidebarTrigger and the AI
button are provided by AppShell.

Button default size shrinks from 48px to 32px (sm variant). Icon
shrinks from 22px to 14px to match.
2026-05-14 22:33:17 +02:00
Roberto
b2b9607f64 fix(contextual): unmount cleanup + projects-list scope precedence
I1: ContextualChatProvider's send-callback IPC listener now stored
in a ref and unsubscribed on provider unmount, preventing leaked
listeners when navigating mid-stream.

m3: ProjectsPage's 'projects-list' scope call is wrapped in a
ProjectsListScope sub-component that only mounts when no project
is selected, so ProjectDetail's project scope is never clobbered
by the parent route's later effect.
2026-05-14 22:09:39 +02:00
Roberto
bdc9411782 feat(contextual): main process bridge for contextual chat
ai.chat tRPC mutation accepts mode='contextual' + scope (z.unknown)
and routes through orchestrateContextual to backend-client.
New sendContextualRequest/sendContextualScopeUpdate methods on
BackendClient mirror sendFloatingRequest plumbing. IPC handler
ai:contextual-scope-update registered in main; preload exposes
sendContextualScopeUpdate on window.electronAI. Drops as never
cast in ContextualChatContext now that schema accepts the call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 22:03:35 +02:00
Roberto
8529c3f0b6 feat(contextual): trigger + scope hook on Timeline/Tasks/Projects/Notes
Each page publishes its scope on render and renders the
AdiuvaTriggerButton in its header. Project detail derives counts
from existing queries. Loading states yield a partial scope with
entityType=null until data arrives.
2026-05-14 21:58:21 +02:00
Roberto
732235c93a feat(contextual): mount ContextualSidebar via ResizablePanelGroup in AppShell
Provider wraps the Outlet so contextual chat survives all route
transitions. The sidebar is hidden on the home route and when
chat.open is false. Sidebar size is provider-owned and persisted
to localStorage.
2026-05-14 21:55:53 +02:00
Roberto
539beaf225 feat(contextual): ContextualSidebar shell
Top-right elevated controls (new chat, close) and a ChatSurface in
contextual variant. No header, no scope chip. Not yet mounted into
AppShell (M4.5).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 21:52:31 +02:00
Roberto
f9eb4b41b6 feat(contextual): adiuva trigger button + compass icon
Elevated 48px button with continuous compass-settle animation.
Hover deepens shadow and adds a gold ambient glow. .sm variant
(32px) reused by sidebar controls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 21:51:59 +02:00
Roberto
4e42ac8b04 feat(contextual): useContextualScope hook
Pages call this in render with their current scope. Provider diffs
by JSON key and only fires the WS scope update on real change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 21:51:02 +02:00
Roberto
869e0d82ee feat(contextual): ContextualChatProvider
Holds open/size/sessionId/scope/messages/streaming state. Creates
or hydrates a contextual aiChatSessions row on mount, persists
messages, and fires scope updates through window.electronAI when
the renderer scope changes. Not yet mounted into AppShell (M4.5).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 21:50:30 +02:00
Roberto
49c0ae2413 fix(drizzle-executor): split comma-separated filter values into IN clause
Backend agent tools (e.g. list_tasks, count_tasks) pass multi-value
filters as a single comma-joined string like status="todo,in_progress".
The generic buildConditions matched that with eq(status, 'todo,in_progress'),
which matched zero rows — agent reported "no tasks found" for any
multi-status query. Now split on comma and emit inArray(col, parts)
when more than one part is present. Also accepts an array directly.

Repro: home chat, ask "what tasks are overdue?". Trace b09714e6e14825f5fa3a226cad053647.
2026-05-14 20:54:29 +02:00
Roberto
4b5f379126 feat(chat): persist home chat history to SQLite
Home chat now creates an aiChatSessions row on first use and
appends every user/assistant message. Session id persisted in
localStorage so reopening the app reattaches to the same row.
Hydration of past messages into the in-memory cache is deferred
to a follow-up — current visible behavior matches the previous
in-memory cache.
2026-05-14 20:11:44 +02:00
Roberto
aad8292f9e fix(chat): useChatStream appends entity-tag mutations like useAIChat
Critical fix: stream_end was dropping event.mutations, which the
baseline useAIChat parses into inline entity tags. Without this,
any consumer migrated from useAIChat to useChatStream would
silently lose entity card rendering on assistant messages.

Exports parseMutationsToEntityTags from useAIChat for reuse.
Also adds the missing 'stream_start' no-op switch case.
2026-05-14 19:11:10 +02:00
Roberto
44a21d662d refactor(chat): home AIChatPanel uses ChatSurface
Pure refactor, no behavior change.
2026-05-14 19:03:33 +02:00
Roberto
ae2cef4335 refactor(chat): extract ChatSurface presentational component
Shared between home and contextual channels. Variant prop selects
between home (full-width, fixed-bottom input) and contextual
(absolute-positioned translucent input with gradient fade) layouts.
2026-05-14 18:59:49 +02:00
Roberto
57462af4f4 refactor(chat): extract useChatStream hook
Shared streaming engine for home (and forthcoming contextual)
channels. useAIChat still owns cache-key + tRPC dispatch; that
wiring is migrated in the next commit.
2026-05-14 18:57:12 +02:00
Roberto
425025ad68 feat(router): add aiChat tRPC sub-router
CRUD for chat sessions and messages, used by both home and contextual
channels. No UI consumer yet — added ahead of refactor.
2026-05-14 18:53:03 +02:00
Roberto
b879760013 chore: gitignore local dev.db used by drizzle-kit push
drizzle-kit push connects to ./dev.db for local schema
verification. The file should not be tracked.
2026-05-14 18:48:55 +02:00
Roberto
21aa1db07e feat(db): add ai_chat_sessions and ai_chat_messages tables
Local chat history persistence. Same model used by both home and
contextual channels. Indexes on (session_id, created_at) and
(channel, updated_at) for ordering and listing.
2026-05-14 18:46:39 +02:00
Roberto
81fe6d29e2 perf(DateTimeField): keep typing local, memoize Calendar + SegmentSpan
Typing in a segment no longer calls onChange — local state only.
onChange now fires only on commit (Enter, calendar pick), so the
parent TaskFormDialog stops re-rendering on every keystroke (and
the heavy Calendar grid + every pill / popover / query stops
re-rendering with it).

Inside DateTimeField:
- Calendar element memoized via useMemo keyed on the committed
  date's ms — only re-renders when a full valid date is reached
  or changes.
- SegmentSpan wrapped in React.memo.
- onSegKeyDown stabilized via useCallback + functional setSeg +
  refs for order / withTime / onChange / onCommit, so its
  identity never changes.
- Per-segment ref setters cached in a useRef map so they don't
  swap identity on each render.

TaskFormDialog:
- onChange / onCommit passed to DateTimeField wrapped in useCallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 13:46:47 +02:00
Roberto
b2d7fa1723 fix(DateTimeField): drop value-sync useEffect that wiped partial typing
Each onChange propagated to the parent caused a re-render with a fresh
Date instance, which retriggered the value-sync effect and overwrote
the in-progress segment state. Result: after picking a day in the
calendar, typing '14' in the hour field only kept the last digit.

Initial value still seeds segment state via the useState lazy
initializer, and Radix Popover unmounts the content on close so each
open starts from the current value.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 13:09:21 +02:00
Roberto
4c641ab93a fix(DateTimeField): autocomplete missing segments on Enter, keep popover open on calendar pick
Enter inside any segment now commits even when the value is partial.
Missing segments default to today (day, month, year) or to 00
(hour, minute). Out-of-range values clamp to their segment max,
and an impossible day-in-month (e.g. Feb 31) clamps to the last
day of the resolved month.

Calendar click no longer fires onCommit, so the Due popover stays
open and the date segments update inline. The popover closes via
Enter inside a segment, Esc, or click outside.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 12:56:02 +02:00
Roberto
84720ff23c feat(TaskFormDialog): segment-based DateTimeField for Due, header padding, assignees kbd wrap
- New DateTimeField component: segment editor (DD/MM/YYYY HH:MM) honoring
  FormatPrefs.dateFormat. Each segment is keyboard-driven (digits to
  enter, arrows up/down to inc/dec, arrows left/right or separators to
  move, Enter commits and closes the Due popover). Calendar grid below
  stays in sync.
- TaskFormDialog: header gains px-5 pt-5 pb-2 so the title+description
  don't sit flush against the DialogContent edges.
- AssigneesList: ArrowDown on the last list item now focuses the
  "new name" Input; ArrowUp from the Input returns to the last list
  item; Esc on the Input closes the popover.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 12:38:27 +02:00
Roberto
d7307e146a fix(TaskFormDialog): control Due popover open state for keyboard close
Add dueOpen state + onOpenChange so Esc and outside-click close the
popover consistently with the other pills; wire DateField.onCommit
to close the popover after Enter or calendar click.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 11:21:42 +02:00
Roberto
7d4059ca4b i18n: add tasks.newTaskDescription / editTaskDescription
Two new keys for the DialogDescription line under the TaskFormDialog
header. Added to all five supported languages (en, it, es, fr, de).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 11:16:56 +02:00
Roberto
9691842e79 feat(TaskFormDialog): new header, full keyboard nav, DateField-based due
Header: DialogTitle + DialogDescription, no separator border, matching
AddEventDialog.
Keyboard: pills row uses roving tabindex (←/→/↑/↓ + Home/End +
Enter-to-open). Each list popover (Project, Priority, Status,
Assignees) uses useListboxKeys for ↑/↓/Home/End/Enter/Space/Esc/Tab.
Due: replaced bespoke Calendar + hour/minute Selects with a
DateField (flat + withTime), which is keyboard-typeable and
format-prefs aware. derivePartsInTz / updateDueTime / HOURS /
MINUTES helpers removed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 11:15:18 +02:00
Roberto
094840e671 refactor(PropertyPill): render as button with forwardRef and focus ring
Switch the trigger element from a presentational <span> to a real
<button type="button"> with forwardRef so the pill can receive keyboard
focus, be used directly as a PopoverTrigger asChild, and show a
focus-visible ring. Public API stays compatible: icon, label, value,
empty plus any standard button HTML attributes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 11:07:55 +02:00
Roberto
e8592b25a8 feat(date-field): add withTime and flat props
withTime renders hour/minute selects under the Calendar and appends
HH:MM to the display text when the value has a non-midnight time.
flat renders Input + Calendar (+ Time row) inline without the
internal Popover, so a caller can embed DateField inside its own
popover without nesting. Existing callers continue to work
unchanged (both props default to false).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 11:05:31 +02:00
Roberto
27b385df53 feat(parseDate): accept optional ' HH:MM' suffix
Existing callers unaffected: a bare date still parses to midnight as
before. New behavior: an optional trailing ' HH:MM' is stripped from
the input, the date portion goes through the existing resolution
order, and setHours/setMinutes is applied to the result. Out-of-range
times (e.g. 25:00, 12:99) return null.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 11:01:28 +02:00
Roberto
e170844f17 feat: add useListboxKeys hook for popover list keyboard
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 10:58:49 +02:00
Roberto
27c1194384 feat: add useRovingFocus hook for roving-tabindex groups
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 10:57:03 +02:00
Roberto
26ea095f60 ui(AddEventDialog): add DialogDescription to match project dialog header 2026-05-14 08:58:57 +02:00
Roberto
751d16a9f4 fix(AddEventDialog): clear focusedRowId on row blur + skip X icon in Tab cycle 2026-05-14 08:52:39 +02:00
Roberto
285214a2d2 ui(AddEventDialog): swap ToggleGroup for Tabs to match Gantt zoom selector 2026-05-14 08:38:10 +02:00
Roberto
89645f2abd polish(timeline): end-date validation + project-lock hint
- Surface invalidMessage on end DateField when end < start
- Auto-clear endDate when start changes past it
- Add title tooltip on SelectTrigger when project is locked

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 19:06:40 +02:00
Roberto
7dadeb88fe fix(AddEventDialog): reset edit mode when removing the row being edited 2026-05-13 19:05:27 +02:00
Roberto
13531fec40 feat(timeline): keyboard nav + edit mode for staged rows
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 19:01:37 +02:00
Roberto
e254efd420 a11y(AddEventDialog): i18n staged-list label + add title aria-label 2026-05-13 18:59:44 +02:00
Roberto
6d79911414 feat(timeline): batch-stage flow in AddEventDialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 18:55:53 +02:00
Roberto
69a859e19f i18n(timeline): add keys for batch-add dialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 18:52:30 +02:00
Roberto
098ce86c76 fix(EditEventDialog): remove non-null assertion via inline expression 2026-05-13 18:50:17 +02:00
Roberto
9ef809ba02 refactor(timeline): migrate EditEventDialog to DateField
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:56:32 +02:00
Roberto
024d572ebb fix(DateField): wire aria-describedby + prevent calendar-icon focus steal
- Add useId() to generate stable fieldId/errorId; link <Input> to error
  <p> via aria-describedby so screen readers announce the message.
- Add onMouseDown={e => e.preventDefault()} to the calendar icon Button
  so clicking it does not trigger onBlur on the input mid-typing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:49:38 +02:00
Roberto
d24f09bbea feat(ui): add DateField with typed entry + calendar popover 2026-05-13 17:45:51 +02:00
Roberto
56fe6c0754 i18n(date): add date keyword arrays for parseDate 2026-05-13 17:25:41 +02:00
Roberto
c76de207d7 fix(date): re-validate leap-day roll-forward; tighten parseDateRange separator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:10:08 +02:00
Roberto
4e89a7a96c feat(date): add parseDate utility with locale-aware parsing 2026-05-13 16:04:44 +02:00
Roberto
0fc3aa421e fix(adiuvAI): WsStreamEndSchema accepts null mutations/error (backend emits null for zero-tool turns) 2026-05-13 09:20:45 +02:00
Roberto
c10fbe22d7 fix(adiuvAI): cap default DialogContent at sm:max-w-lg 2026-05-13 08:34:37 +02:00
Roberto
e3e0b06fb6 fix(adiuvAI): add 3 folder actions to ToolCallActionSchema enum (caused silent WS hang) 2026-05-12 17:57:52 +02:00
Roberto
b3d85b93f1 feat(adiuvAI): drizzle-executor read action returns kind+totalSize, supports offset/length 2026-05-12 17:34:19 +02:00
Roberto
659607a1e9 fix(adiuvAI): remove card border from FolderLinkCard, inline layout 2026-05-12 14:46:09 +02:00
Roberto
80a0d2c56f fix(adiuvAI): files section uses standard project section layout 2026-05-12 14:43:38 +02:00
Roberto
66448a25f4 fix(adiuvAI): collapse chip height in compact mode to preserve hero shrink 2026-05-12 14:32:51 +02:00
Roberto
93144b9de8 fix(adiuvAI): position FolderChip right of project title 2026-05-12 14:30:10 +02:00
Roberto
b0c415f90f feat(adiuvAI): pre-flight quota check + error toasts for folder integration
Before starting an index session, scanFolder is called to count
indexable files, then BackendClient.checkFolderQuota POSTs to
/api/v1/billing/quota/check.  A 402 response becomes a TRPCError
FORBIDDEN with a QUOTA:<reason>:<message> payload.  FilesSection
catches that payload and shows a localised sonner toast via
projects.folder.errors.tooBig or monthlyExhausted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:10:14 +02:00
Roberto
8a2225da7c feat(adiuvAI): wire FolderChip + FilesSection into ProjectDetail 2026-05-12 13:04:02 +02:00
Roberto
e0c5971d20 feat(adiuvAI): add 'files' tab to ProjectTabBar 2026-05-12 13:02:01 +02:00
Roberto
a499d55636 feat(adiuvAI): FilesSection orchestrator 2026-05-12 12:50:27 +02:00
Roberto
c36890cc8b feat(adiuvAI): FolderUnlinkDialog 2026-05-12 12:49:10 +02:00
Roberto
b80ba0434b feat(adiuvAI): FolderFileList with kind filter 2026-05-12 12:48:39 +02:00
Roberto
01d3735dd1 feat(adiuvAI): FolderLinkCard component 2026-05-12 12:47:57 +02:00
Roberto
e0bcb2fe0a feat(adiuvAI): FolderChip component 2026-05-12 12:47:13 +02:00
Roberto
a1c83a6134 i18n: projects.folder keys in all 5 locales 2026-05-12 12:41:04 +02:00
Roberto
bd5e3076ed feat(adiuvAI): daily auto-rescan of stale folder links 2026-05-12 12:23:27 +02:00
Roberto
316b8fa66a feat(adiuvAI): drizzle-executor folder manifest + scoped read actions 2026-05-12 12:21:36 +02:00
Roberto
6f907f6a96 feat(adiuvAI): projectFolders tRPC router (link, unlink, scan, list) 2026-05-12 12:18:50 +02:00
Roberto
93caf0116d feat(adiuvAI): WS index session orchestrator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:09:36 +02:00
Roberto
15af8d54e6 feat(adiuvAI): WS index session frame senders + dispatcher
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:06:48 +02:00
Roberto
c4ed7b3482 feat(adiuvAI): folder scanner with mtime delta 2026-05-12 12:04:04 +02:00
Roberto
066d407a5f feat(adiuvAI): folder index constants 2026-05-12 12:03:20 +02:00
Roberto
c2826ae4be feat(adiuvAI): schema for project folder integration
Add folderPath, folderLastScannedAt, folderLastScanStatus, folderTotalFiles
columns to projects table; add project_folder_files table with kind/summary
columns. Migration: 0005_slim_baron_strucker.sql.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:00:36 +02:00
Roberto
adb1cc81ef chore: remove kbd hint from TaskFormDialog header 2026-05-08 16:11:49 +02:00
Roberto
a4fd10e640 fix: TaskDetailSheet priority/status popovers auto-close on selection 2026-05-08 16:08:44 +02:00
Roberto
efa3051c61 fix: task UX polish — card menu, sheet live render, composer align, project link, no comment toast
- TaskCard: replace checkbox toggle with right-click ContextMenu (Edit / Change Status submenu / Delete), matching TaskTableRow flow; status now visible via shared StatusBadge in card footer
- TaskTableRow + TaskCard: add RefreshCw icon to Change Status submenu trigger
- TaskDetailSheet: subscribe to fresh row via tasks.byIds and render liveTask so priority/status chip popovers reflect mutations immediately; invalidate byIds alongside tasks.list on update
- ChatInputBox 'comment' variant: items-end -> items-center so single-line placeholder aligns with send button
- TaskTableRow: remove project-cell click handler and underline; remove onProjectClick prop chain from TaskTable
- TaskDetailSheet header breadcrumb: now a button navigating to /projects?projectId=... (closes sheet first)
- TaskDetailSheet addComment: drop success toast on create, keep error toast and cache invalidation
2026-05-08 16:00:55 +02:00
Roberto
72e09501de fix: TaskDetailSheet X close + overflow menu aligned in same row 2026-05-08 15:37:55 +02:00
Roberto
875fe625b5 fix: TaskFormDialog due-date time picker; TaskDetailSheet header X/menu overlap 2026-05-08 15:24:04 +02:00
Roberto
dac1d50b02 refactor: replace hand-rolled DB migrations with Drizzle migrator
Drop the MIGRATION_SQL string + try/catch ALTER TABLE block from initDb()
in favor of drizzle-orm/better-sqlite3/migrator, which reads
src/main/db/migrations/ (the canonical drizzle-kit output) and applies
each *.sql in order, tracked via __drizzle_migrations.

This fixes a class of bugs where schema.ts + a generated migration ship
correctly but db/index.ts is forgotten — most recently 0004
(estimate column + task_attachments table), which silently broke
tasks.list on existing DBs.

Migration folder resolution:
- Packaged: <resourcesPath>/migrations (declared as extraResource in
  forge.config.ts so it lands next to the asar)
- Dev: <appPath>/src/main/db/migrations (Vite bundles main into a
  single main.js, so __dirname is not next to the migrations folder)

Bootstrap for legacy DBs: pre-existing DBs created by the old
hand-rolled MIGRATION_SQL have all tables from 0000-0003 but no
__drizzle_migrations ledger. We detect this on startup (tasks table
present, ledger missing), seed the ledger marking all but the latest
migration as applied, then let the migrator run only the new one.
This preserves existing data and the migrator's hash check on
subsequent runs.

Verified locally: real user DB (51 tasks) migrated cleanly — estimate
column added, task_attachments table created, all rows preserved.

Future schema changes: edit schema.ts → npx drizzle-kit generate → commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:11:52 +02:00
Roberto
e104ffc3ab feat(i18n): add attachment toast keys for all 5 languages 2026-05-08 14:44:39 +02:00
Roberto
1cffb9bdbf feat(i18n): add task list/sheet/dialog keys for all 5 languages 2026-05-08 14:42:27 +02:00
Roberto
bae84f1a48 refactor: project page tasks tab uses TaskListView with hideProjectColumn 2026-05-08 14:36:24 +02:00
Roberto
938c8eef8a refactor: tasks route uses TaskListView; extract TaskItem to task-types 2026-05-08 14:34:42 +02:00
Roberto
50d01c7aec feat: TaskListView orchestrator (toolbar + table/grid + pager) 2026-05-08 14:30:29 +02:00
Roberto
ef04bec66f feat: TaskPager with numbered buttons and ResizeObserver-aware width 2026-05-08 14:28:38 +02:00
Roberto
2e9ec31d83 feat: TaskTable + TaskTableRow with context menu and status submenu 2026-05-08 14:27:02 +02:00
Roberto
ca290225b9 feat: TaskFormDialog edit-mode 📎 attach pill 2026-05-08 14:19:08 +02:00
Roberto
a5ec0647ec refactor: NewTaskDialog/EditTaskDialog become wrappers around TaskFormDialog 2026-05-08 14:14:09 +02:00
Roberto
57f5470f0d feat: inline project/client/assignee creation in TaskFormDialog pills 2026-05-08 14:12:23 +02:00
Roberto
33e5edc2ba feat: TaskFormDialog property pills with popover editors 2026-05-08 14:08:47 +02:00
Roberto
fadda94135 feat: TaskFormDialog shell with title/description 2026-05-08 14:06:44 +02:00
Roberto
5fa3df9c16 feat: TaskDetailSheet — clickable priority/status chips 2026-05-08 14:04:49 +02:00
Roberto
b48ceea0af refactor: replace TaskDetailDialog with TaskDetailSheet 2026-05-08 14:03:30 +02:00
Roberto
9e31cfa78e feat: TaskDetailSheet description, comments, and ChatInputBox composer 2026-05-08 14:01:34 +02:00
Roberto
c63c94b561 feat: TaskDetailSheet attachments inline strip with add-file flow 2026-05-08 13:59:25 +02:00
Roberto
cbdb37f5a5 feat: TaskDetailSheet properties card (assignee/due/estimate/created) 2026-05-08 13:57:16 +02:00
Roberto
05de7405ba feat: TaskDetailSheet header — breadcrumb, title, chips, overflow menu 2026-05-08 13:48:30 +02:00
Roberto
68286b61bd feat: add TaskDetailSheet skeleton (sticky header/body/composer) 2026-05-08 13:47:23 +02:00
Roberto
a7fbc4c7e3 feat: add 'comment' variant to ChatInputBox 2026-05-08 13:46:20 +02:00
Roberto
1a5605569c feat: add TaskAttachmentChip + useTaskAttachments hook 2026-05-08 13:45:17 +02:00
Roberto
ef71710244 feat: add StatusBadge component
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:41:38 +02:00
Roberto
ca78a4cbc0 feat: add AssigneeStack component 2026-05-08 13:40:16 +02:00
Roberto
b652248404 feat: tasks.update accepts estimate; tasks.delete cascades attachments 2026-05-08 13:39:15 +02:00
Roberto
f5ac37867c feat: add taskAttachments tRPC sub-router (list/pick/create/delete/open) 2026-05-08 13:28:04 +02:00
Roberto
37878df992 feat: add attachments storage helper module 2026-05-08 13:20:52 +02:00
Roberto
9e90791743 feat: add tasks.estimate column and task_attachments table 2026-05-08 13:19:16 +02:00
Roberto
dd3f1442b0 Improve timeline axis labels and gantt grid rendering
- Add year row in month-zoom axis header
- Center secondary tick labels within their column
- Tighter day column (46px → 32px) and normalized day boundaries
- Render explicit grid lines per zoom level (day/week/month)
- Switch sticky label background from blur to solid bg-background

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 18:11:32 +02:00
Roberto
a5556743f0 Align sidebar trigger position across home, note, brief
Match standard h-14 px-3 header from timeline/tasks pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 18:11:24 +02:00
Roberto
ca231e7b7c Add task briefing carousel with per-task AI research and canvas panel
- New brief/ components: TaskBriefingOverlay, TaskCarousel, TaskBriefChat,
  BriefChatHeader, CanvasPlaceholder, CarouselControls, TaskBriefEmptyState
- ResizablePanelGroup splits chat/canvas when draft present; pill handle in primary color
- taskBriefings SQLite table + tRPC endpoints: taskBriefResearch, getTaskBriefing,
  invalidateTaskBriefing; briefings cached, invalidated on task update
- Stage 1 deep-research agent streams briefing + optional canvas draft via IPC
- Stage 2 follow-up chat injects briefing context into floating mode
- Trackpad horizontal scroll navigation (deltaX threshold + 600ms throttle)
- canvas block stripped from chat panel, rendered only in canvas pane
- i18n keys added across all 5 locales (en/it/es/fr/de)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 15:09:36 +02:00
Roberto
a5a6e25a89 Update the background 2026-05-03 22:52:10 +02:00
Roberto
df8cbb5c35 Update note management from db vector to index 2026-04-30 00:11:25 +02:00
Roberto
d0b344beec timeline resize view 2026-04-29 23:13:29 +02:00
Roberto
1f4adfca90 Update project circle 2026-04-29 14:42:21 +02:00
Roberto
259ab50b25 Update projects page view 2026-04-29 09:31:15 +02:00
Roberto
a04c2434b6 fix tools calls 2026-04-27 09:15:04 +02:00
Roberto
c291fc689a timeevent and date formt fix 2026-04-26 21:06:20 +02:00
Roberto
b61a6de73a Step 1: Improve timeline event view 2026-04-23 00:07:18 +02:00
Roberto
f2a68ee5f6 update tasks visualization 2026-04-22 00:13:22 +02:00
Roberto
0c43f5633f flex-wrap to filter section in the task page 2026-04-21 23:23:59 +02:00
Roberto
4ebf0d4062 Keep project client open 2026-04-21 23:17:27 +02:00
Roberto
244d53f93d Update note view and sidebar where now it visible the project 2026-04-21 23:02:01 +02:00
Roberto
8dceacc2ce Review project page 2026-04-21 21:42:49 +02:00
Roberto
7244810fe1 add daily brief agent 2026-04-19 14:47:47 +02:00
Roberto Musso
e9c790e017 PHASE 4 — Settings > Memory UI (Electron renderer) 2026-04-17 17:04:50 +02:00
Roberto Musso
9b32d834b3 Update setting page 2026-04-15 11:44:40 +02:00
Roberto Musso
333b6cb769 feat(notifications): add sonner toasts to auth and onboarding flows 2026-04-12 18:17:18 +02:00
Roberto Musso
87c444e78d feat(notifications): add sonner toasts to all CRUD operations 2026-04-12 18:13:52 +02:00
Roberto Musso
811759dddb feat(notifications): replace settings saved-state patterns with sonner toasts 2026-04-12 18:06:50 +02:00
Roberto Musso
275edab4bf feat(notifications): add sonner toast foundation with useNotify hook and i18n keys 2026-04-12 18:04:29 +02:00
Roberto Musso
0371a46731 feat(i18n): add multilanguage support (EN/IT/ES/FR/DE) with optimized shared keys
- Add i18next + react-i18next with bundled JSON translations
- Translate all pages: Home, Tasks, Timeline, Projects, Settings, Auth, Agents
- Language selector in Settings > General syncs to electron-store + backend memory
- AI daily brief and agent responses respect selected language
- Optimize translation files: consolidate 16 duplicate keys into common.* namespace
  (add, rename, save, edit, delete, saving, deleting, creating, renameDescription, deleteTitle)
- LanguageSync component in root restores persisted language on startup
2026-04-12 00:33:14 +02:00
Roberto Musso
cd8f6a6751 feat: onboarding wizard - multi-step flow, locale detection, profile settings, user_name in core memory 2026-04-11 23:40:12 +02:00
Roberto Musso
dd98aaaf4d feat: add seed script for populating database with fake data and logging for agent triggers 2026-04-11 02:13:56 +02:00
Roberto Musso
20bc28e59b feat: replace _cachedPassword with device-specific backup key
Add backup-key.ts that generates a random 256-bit key on first use and
persists it via safeStorage + electron-store (same pattern as token.ts).
Remove _cachedPassword and getCachedPassword() from AuthManager — they
were unused since BackupManager does not exist yet. Social-login users
can now use backup features without being tied to a password.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:41:48 +02:00
Roberto Musso
5d112c8dfd feat: implement Google OAuth flow — deep link, auth-manager, login UI, avatar
Step 3 (deep link + auth manager):
- forge.config.ts: register adiuvai:// protocol for packaged app
- index.ts: single-instance lock, setAsDefaultProtocolClient (dev + prod),
  second-instance (Windows/Linux) and open-url (macOS) handlers
- auth-manager.ts: loginWithOAuth() opens browser + awaits deep-link promise;
  handleOAuthCallback() parses adiuvai://oauth/callback, exchanges code via
  POST /auth/oauth/{provider}/callback, resolves pending promise
- router/index.ts: auth.loginWithOAuth tRPC mutation

Step 4 (UI + avatar):
- LoginForm.tsx: Google button with inline SVG icon, divider, "Waiting for
  browser..." pending state; isBusy guards both mutations
- AppShell.tsx: AvatarImage added to NavUser (sidebar trigger + dropdown);
  avatarUrl propagated through AppSidebarProps and NavUser types
- AccountSection.tsx: avatar with photo/initials fallback, display name, email
- api-types.ts: avatarUrl added to UserProfileSchema (camelCase, nullable)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:04:02 +02:00
Roberto Musso
27bc9d90af feat: enhance agent scheduling and prompt builder functionality 2026-04-10 08:45:45 +02:00
Roberto Musso
016c44c6f0 remove unecessary indications 2026-04-09 00:41:02 +02:00
Roberto Musso
02a0f3635b update app name 2026-04-08 23:27:03 +02:00
Roberto
109551f713 Merge branch 'develop' of https://git.muticolturano.com/Adiuva/adiuva into develop 2026-04-08 23:14:31 +02:00
f129b3ba43 Merge branch 'develop' of https://git.muticolturano.com/Adiuva/adiuva into develop 2026-04-08 22:04:43 +02:00
7f0c6f45b0 feat(local-agent-v2): step 5 — migrate promptTemplate → agentConfig in FE
- store.ts: LocalAgentLocalConfig.promptTemplate (string) → agentConfig (Record | null)
- agent-scheduler.ts + router runNow: send agentConfig object to trigger, drop customAgentPrompt
- api-types.ts: WsJourneyReplySchema + LocalAgentConfigSchema + JourneyMessageSchema use agentConfig
- WsJourneyStartSchema: existingTemplate → existingConfig (aligns with backend existing_config field)
- backend-client.ts: JourneyListener + sendJourneyStart + journey_reply handler use agentConfig
- router/index.ts: local agent create/update accept agentConfig; journey router returns agentConfig
- types.ts + AgentsSection + JourneyDialog: promptTemplate → agentConfig throughout
- JourneyDialog: parses JSON agentConfig string → object; shows AgentConfigSummary preview
- PromptBuilderChat: adds onConfigUpdate callback for local agent path (cloud keeps onPromptUpdate)
- InlineAgentCreationStepper: local path uses agentConfig state; cloud path keeps promptTemplate

Cloud agents are intentionally NOT migrated — they retain promptTemplate string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:03:26 +02:00
Roberto
2caea8e21d rebrand: Adiuva → adiuvAI with new compass logo
Replace generic star icon and "Adiuva" text with new compass mark and
"adiuvAI" wordmark across sidebar, login form, and AI chat header.
Add app icon (PNG/ICO) and configure Forge packager and BrowserWindow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 15:26:44 +02:00
Roberto
b23c4ef255 Merge branch 'develop' of https://git.muticolturano.com/Adiuva/adiuva into develop 2026-04-08 09:54:12 +02:00
Roberto
801ae43000 Add logo brand 2026-04-08 09:53:48 +02:00
bd9af5ddd6 refactor: remove backup, storage, and plugin types from Electron app
- Delete src/main/backup/ (backup-manager, e2e-crypto, sync-queue)
- Remove backup lifecycle from index.ts and router
- Remove syncQueue table from db/schema.ts
- Remove backupEnabled/backupIntervalHours/lastBackupAt from store
- Remove uploadBackup/downloadBackup from backend-client
- Update embed URL to /api/v1/chat/embed
- Remove PluginListing, InstalledPlugin from batch-types
- Remove PermissionGrant, BackupMetadata from api-types
2026-04-08 00:48:00 +02:00
3ae9e450be Fix ProjectSidebar scroll and style native scrollbar
- Constrain SidebarProvider to h-full to close height chain
- Replace Radix ScrollArea in ProjectSidebar with overflow-y-auto div
  (Radix needs explicit pixel height; flex-1 alone is unreliable)
- Add min-h-0 to ProjectSidebar root to allow flex shrink
- Style native webkit scrollbar to match shadcn ScrollBar component
  (w-2.5, bg-border thumb, rounded-full, transparent track/corner)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:44:43 +01:00
7616153345 Remove isApproved from tasks, rework agent runner, fix layout overflow
- Remove isApproved column from tasks DB schema and migration; drop column on startup
- Remove isApproved from tRPC router (list, create, update queries)
- Remove isApproved filter from KanbanBoard and ProjectDetail approve/reject UI
- AI-generated tasks now auto-approved; show Sparkles icon via isAiSuggested flag
- Fix tasks page width overflow: add min-w-0 to SidebarInset in AppShell
- Fix task title overflow: truncate with ellipsis inside TaskRow
- Fix tasks toolbar layout: shrink-0 on right side, fixed w-56 on search input

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 22:20:55 +01:00
0c21f47a59 removed unused files 2026-03-20 12:47:33 +01:00
7256f1ef4e Fix runNow: pass agentId and create run row in local SQLite
The manual 'Run now' path was missing both agentId in the trigger
request (so BE couldn't echo it in run_context) and the agentRuns
insert after the trigger responded, so manually-triggered runs never
appeared in the history sheet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:54:54 +01:00
bf635d9c30 Always record agent run even when no actions are taken
Create the agentRuns row immediately after the trigger POST responds,
before any tool calls arrive. This ensures runs with zero mutations
(agent found nothing to create/update) still appear in the history sheet.

Removed the redundant onConflictDoNothing guard from recordRunAction
since the row is guaranteed to exist by trigger time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:46:42 +01:00
5add259348 Fix await in sync WS message handler for run_complete
Wrap the async db.update in void (async () => {})() like the tool_call
case does — the ws.on('message') callback is synchronous.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:16:18 +01:00
198fd62ef2 Add agent run history sheet with action breakdown
- agent.runs tRPC procedure now queries local SQLite agentRuns table
  (previously fetched from backend) and joins action counts per run
- agent.runActions procedure added for lazy-loading individual actions
  when a run is expanded in the sheet
- AgentRunHistorySheet: slide-in sheet opened via History button on the
  agent card; shows runs with status/duration/action summary; each run
  is expandable to list individual actions (created/updated/deleted)
  with entity type and title
- AgentRow: adds History button, removes embedded AgentRunLog from
  expanded config section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:11:40 +01:00
34a771bee3 Implement agent run logging in local SQLite
Protocol:
- RunContextSchema added to api-types — attached to WsToolCall frames
  originating from batch runs; type/runId/agentId identify the run
- WsRunCompleteSchema added — server sends this when a batch run ends

Database:
- agent_runs table: one row per run (id, agentId, status, startedAt, completedAt)
- agent_run_actions table: one row per mutating tool call
  (verb: created/updated/deleted, entityType, entityId, entityTitle)

Logging logic (backend-client.ts):
- On tool_call with runContext: ensure agentRuns row exists, insert
  agentRunActions for insert/update/delete actions
- On run_complete: update agentRuns status and completedAt

Scheduler passes agentId in the trigger POST so the backend echoes it
back in run_context for correct attribution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 09:46:48 +01:00
65a08838c9 Truncate WS log output to 200 chars
Prevents large tool_result payloads from flooding the dev console.
Both send and receive logs now append … when the serialised frame
exceeds 200 characters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:04:16 +01:00
8b5a05a16e Refactor settings page into pluggable components
- Split monolithic settings.tsx (~1500 lines) into focused components under
  src/renderer/components/settings/: GeneralSection, AccountSection,
  AgentsSection, AgentRow, LocalAgentConfigPanel, CloudAgentConfigPanel,
  TemplateSelectCard, PromptBuilderChat, InlineAgentCreationStepper,
  JourneyDialog, SettingsCard, and shared types/constants
- Hide agents list while creation stepper is open
- Use ScrollArea (app scroll primitive) in PromptBuilderChat
- Fix done-state handling: filter empty AI messages, show hardcoded
  confirmation bubble only once, move saved badge below chat, keep
  input enabled after prompt is saved so user can keep refining
- Wrap LocalAgentConfigPanel footer buttons with flex-wrap for narrow cards
- Update Agents section title/subtitle copy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:52:39 +01:00
6a87590176 Update shadcn to v4, fix sendHomeRequest call signature, refresh skills lock
- Upgrade shadcn from 3.8.5 to 4.0.8
- Add missing session_id parameter to sendHomeRequest calls in orchestrator
- Update skills-lock.json computed hashes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 16:34:07 +01:00
cd4644637b Wire journey chat to WS backend and handle end-of-conversation
- Rewrite PromptBuilderChat to use real WS journey mutations with
  button-to-start pattern, loading states, and markdown rendering
- Add isDone state to both PromptBuilderChat and JourneyDialog so
  input is disabled and a confirmation banner shown after prompt generation
- Extract and save promptTemplate via onPromptUpdate when BE sends done=true

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 16:26:21 +01:00
9fd441e7d7 Refactor Local Directory Monitor Agent to two-phase BE-orchestrated architecture
Replace the old single-pass FE file-reader flow (agent_run → agent_data →
agent_complete) with a BE-orchestrated two-phase execution where the BE's LLM
calls filesystem tools on the FE via tool_call/tool_result WS round-trips.

Key changes:
- Remove deprecated file-reader.ts and agent_run/agent_data/agent_complete frames
- Add list_directory, read_file_content, get_file_metadata handlers to DrizzleExecutor
- Migrate journey setup from REST to WebSocket (journey_start/message/reply frames)
- Store agent configs locally in electron-store (no longer on BE)
- Add agent scheduler for periodic auto-trigger via POST /agents/trigger
- Update device_hello to use local agent configs
- Remove fileExtensions from agent config, switch to single directory path
- Add agent.canCreate quota check mutation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 11:05:08 +01:00
b7ddc95171 Udpate task page 2026-03-16 08:53:08 +01:00
488dab7aa1 Refine floating chat session lifecycle and home page glass effects
- Floating chat: reset session_id only on user-initiated page navigation,
  not when closed via X/Escape (session persists for reopening same context)
- Home buttons (sidebar trigger + new chat): add frosted glass background
  so they remain legible when chat messages scroll behind them
- Daily brief toast: match frosted glass opacity/blur to button treatment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:53:39 +01:00
a52e5362b3 make visible buttons to create new chat 2026-03-16 00:38:16 +01:00
582ad389e1 migrate LanceDB package and include pending UI updates 2026-03-16 00:33:48 +01:00
3283cc9ad5 Add new conversation button and session_id to AI chat
- Add "New conversation" button in home page header, next to SidebarTrigger,
  separated by a vertical divider (visible only after first message)
- Generate and persist session_id per chat context in useAIChat; reset to a
  new UUID on clearMessages so each new conversation gets a fresh session
- Floating chat auto-resets session_id on close (clearMessages already fires)
- Thread session_id through tRPC router → orchestrator → backend-client WS
  payloads (home_request and floating_request) as snake_cased session_id

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:32:54 +01:00
Roberto Musso
396fd2faa4 fix autoscroll aichat 2026-03-15 23:46:01 +01:00
fc71ee6e02 Refactor AI chat rendering and layout stability
- Extract shared AIMessage component to eliminate duplicated markup between
  completed and streaming turns; fix pl-[22px]/pl-[32px] indent inconsistency
- Cache stable CSS layout values (spacing, font-size) in a ref, recomputed
  only on mount and window resize instead of on every message
- Filter whitespace-only text segments in mergeTimelineSegments to prevent
  empty prose divs after timeline tags are stripped
- Group ChatTimelineBlock events by project, rendering one ProjectTimelineBox
  per project instead of collapsing all events into a single timeline
- Apply --ui-scale zoom to body and compensate #root dimensions accordingly
- Fix sidebar svh units to use full height for correct Electron layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 22:46:38 +01:00
96d49abd9a removed unused button 2026-03-13 17:07:08 +01:00
3bc08c6de7 Support structured floating_domain routing and stabilize floating chat stream 2026-03-13 16:10:13 +01:00
8fe2b1c43e Add time-slot and event-driven auto-refresh for daily brief 2026-03-13 12:07:43 +01:00
43cfb694e7 Persist AI chat state across navigation 2026-03-13 10:55:51 +01:00
e9347c5e5a Unify timeline tags into bottom ProjectTimeline block 2026-03-13 00:38:51 +01:00
3bc8ad32cd remove stream_block — parse entity and chart tags inline from text
- Remove WsStreamBlock schema, type, and server frame entry
- Remove stream_block from V3StreamEvent unions (preload, ipcLink)
- Remove onBlock from StreamListener and all WS/IPC wiring
- Remove StreamBlock type, streamingBlocks state from useAIChat
- Convert parseMutationsToBlocks → parseMutationsToEntityTags (inline tags)
- Add inline tag parser: <type>[ids]</type> for entities, <chart>{JSON}</chart>
- Add MessageContent component to render mixed text + entity/chart segments
- Replace BlockRenderer with direct ChatEntityBlock/ChatChartBlock rendering
- Simplify blocks/index.tsx to re-exports only
2026-03-12 00:40:53 +01:00
038cd48285 update daily 2026-03-11 01:12:28 +01:00
8830793105 Update auth token refresh 2026-03-11 01:08:50 +01:00
fa1cd36670 update project timeline view 2026-03-11 00:56:41 +01:00
c61d572023 Update task filter 2026-03-11 00:30:07 +01:00
7af6f0d9e0 bug fix the collapse button 2026-03-11 00:24:29 +01:00
7fd1e85adb update timeline visualization 2026-03-11 00:16:56 +01:00
34e725135d update the vertical in header 2026-03-10 16:32:14 +01:00
1caa930977 update sidebar 2026-03-10 16:13:40 +01:00
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
aec83c30d2 Update plan 2026-03-09 08:09:45 +01:00
3a7a85c617 Create v3 arch plan 2026-03-08 22:53:01 +01:00
3051e6e0a9 update plan 2026-03-05 23:54:30 +01:00
4cd382b829 step 4.1+4.2 complete: E2E encrypted backup + offline sync queue
- e2e-crypto.ts: Argon2id key derivation (time=3, mem=64MB) + AES-256-GCM
  encrypt/decrypt + SHA-256 checksum + ADV1 blob packing (salt+IV+authTag+ciphertext)
- backup-manager.ts: createBackup (WAL snapshot → encrypt → upload), restoreBackup
  (download → verify → decrypt → atomic file swap → reinit DB → notify renderer),
  getHistory, deleteBackup, schedulePeriodicBackup (setInterval, default 24h)
- sync-queue.ts: enqueues failed backup intents in sync_queue table; processQueue
  retries up to 5×; triggered automatically on WS reconnect via onConnected()
- backend-client.ts: uploadBackup (raw PUT /api/v1/backup with custom headers),
  downloadBackup (If-Modified-Since / 304 support), onConnected() event hook
- auth-manager.ts: password cached in memory at login/register, cleared at logout,
  getCachedPassword() for BackupManager — never persisted to disk
- store.ts: backupEnabled, backupIntervalHours, lastBackupAt settings
- db/schema.ts: sync_queue table (id, action, payload, status, retries, timestamps)
- db/index.ts: getRawSqlite() for .backup() API, getDbPath(), closeDb() for restore
- router/index.ts: backupRouter (create/restore/history/delete/settings/updateSettings);
  login starts periodic backup; logout stops it
- index.ts: backup scheduler wired to app lifecycle; will-quit cleans up timer
- package.json: argon2 added

Backend integration: PUT/GET /api/v1/backup already fully implemented; no
backend changes needed. Tier gating (free=0, pro=5GB, power=25GB) enforced
server-side. Backend only verifies SHA-256 checksum — never decrypts.
2026-03-05 22:56:10 +01:00
0d6c688015 step 3.9 complete: agent run logs UI
- AgentRunLog component with status badges, duration, processed/created counts
- Per-run expandable error list (click to reveal all errors)
- Skeleton loading state + empty state
- Lazy-loaded (only fetches when agent row is expanded), limit 10 runs
- Replaces inline Recent Runs block in AgentRow
2026-03-05 17:46:59 +01:00
b804629f91 feat: add settings page with sections for general, account, agents, and appearance 2026-03-05 17:40:43 +01:00
b860e794a3 step 3.5 complete: persistent WS for agent triggers
- BackendClient.connectPersistent(): opens always-on WS to /api/v1/ws/device
  - sends device_hello frame with deviceId + active local agent IDs on connect
  - handles agent_run: reads files → sends agent_data + agent_complete frames
  - handles tool_call: DrizzleExecutor → tool_result (same as chat WS)
  - client-side heartbeat ping every 30s with 10s pong timeout
  - auto-reconnect with exponential backoff (1s→2s→4s→8s→16s→30s cap)
- BackendClient.disconnectPersistent(): clean close, disables reconnect
- handleAgentRunAndSend(): validates device ID (Step 3.3 final checkbox),
  sends agent_data + agent_complete frames over persistent WS; removes TODO
- index.ts: connectPersistent() on startup (if authenticated), will-quit handler
- authRouter.login: connectPersistent() on success
- authRouter.logout: disconnectPersistent()
- Completes Step 3.3 final checkbox (device-ID validation on agent_run)
2026-03-05 16:08:38 +01:00
6f73824e7e step 3.4 complete: agent tRPC router
- Add AgentCatalogItem, LocalAgentConfig, CloudAgentConfig, AgentRunLog,
  JourneyMessage Zod schemas + types to src/shared/api-types.ts

- Add proxyGet/proxyPost/proxyPut/proxyDelete methods to BackendClient
  (authenticated, casing-converted HTTP proxies with retry + auth bypass)

- Add agentRouter to src/main/router/index.ts (14 procedures):
    agent.catalog                     GET /api/v1/agents/catalog
    agent.local.{list,create,update,delete}  with deviceId injected on create
    agent.cloud.{list,create,update,delete}
    agent.runs                        GET /api/v1/agents/runs (paginated)
    agent.runNow                      POST /api/v1/agents/{id}/run
    agent.journey.{start,message}     chatbot journey endpoints

- Merge agent router into appRouter
- Mark Step 3.3 deviceId checkbox done (satisfied by local.create injection)
2026-03-05 15:51:27 +01:00
e132459fef step 3.3 complete: device ID management
- Add deviceId: string to AppSettings (electron-store) with default ''
- Add getDeviceId() helper — lazy UUID v4 generation, persisted on first call
- Add settings.deviceId tRPC query so renderer + agent router can read it
- Local agents will be device-bound (config injection in step 3.4)
2026-03-05 15:33:50 +01:00
43b031de5b step 3.2 complete: local file reader for directory agent
- Create src/main/agents/file-reader.ts:
  - readAgentFiles() — recursive directory walker with extension allowlist
  - extractContent() — dispatches by ext: text/md/eml/csv/json (readFile),
    pdf (PDFParse v2), docx (mammoth.extractRawText), unknown → error entry
  - chunkContent() — splits >50KB content on newline boundaries with chunk
    metadata; 10MB per-file size cap
  - Security: all paths resolved via realpath() before I/O; every path
    checked against allowedRoots to block symlink escapes and .. traversal
- Update BackendClient.handleAgentRun() to call readAgentFiles() and return
  { files, errors, filesRead }; WS transmission deferred to Step 3.5
- Add pdf-parse@^2.4.5 and mammoth@^1.11.0 (pure JS, no packaging changes)
2026-03-05 15:24:29 +01:00
e769ff2806 step 3.1 complete: WS agent frame types + handleAgentRun stub
- Add WsAgentRunSchema (server→client): agent_run with runId, agentId, config
- Add WsAgentDataSchema (client→server): agent_data with files array
- Add WsAgentCompleteSchema (client→server): agent_complete with filesRead + errors
- Add WsDeviceHelloSchema (client→server): device_hello with deviceId + agentIds
- Extend WsServerFrameSchema union to include agent_run
- Extend WsClientFrameSchema union to include agent_data, agent_complete, device_hello
- Add BackendClient.handleAgentRun() stub (full impl in Steps 3.2 + 3.5)
2026-03-05 15:01:46 +01:00
0c8f0c429a step 2.1 complete: no LangChain, no Copilot SDK, no local LLM 2026-03-05 11:22:08 +01:00
35d7c3e710 step 1.6 complete: migrate embeddings to backend
- upsertNoteEmbedding() calls BackendClient.embedText() instead of local LangChain
- Offline graceful degradation: skip embedding with warning, retry on next save
- searchNotes() embeds queries via backend client
- Add searchNotesByVector() for pre-computed vector search (used by DrizzleExecutor)
- drizzle-executor: vector_search now uses searchNotesByVector with backend-provided vector
- Delete src/main/ai/embeddings.ts (LangChain OpenAIEmbeddings removed)
2026-03-05 00:27:49 +01:00
89df7e48ad step 1.5 complete: refactor orchestrator to delegate to backend
- Replace 996-line LangGraph orchestrator with ~190-line backend-delegation layer
- orchestrate() checks online/auth → builds ChatContext from SQLite → delegates to BackendClient.chatStream()
- Remove setToken, hasToken from aiRouter (replaced by auth.status)
- AIChatPanel: trpc.ai.hasToken → trpc.auth.status, update auth-gate messaging
- AppShell: remove Copilot token dialog, replace with auth-status placeholder
2026-03-05 00:23:46 +01:00
212 changed files with 35362 additions and 7944 deletions

View File

@@ -15,7 +15,7 @@ No test suite currently.
## Architecture
Adiuva is a local-first Electron desktop app. The three processes communicate via a custom tRPC v11 ↔ IPC bridge (the public `electron-trpc` package is incompatible with tRPC v11).
AdiuvAI is a local-first Electron desktop app. The three processes communicate via a custom tRPC v11 ↔ IPC bridge (the public `electron-trpc` package is incompatible with tRPC v11).
```
Renderer (React 19) ──ipcLink──► Preload (contextBridge) ──IPC──► Main (tRPC router + SQLite)
@@ -31,8 +31,8 @@ Owns the database and all business logic.
| `ipc.ts` | Bridges `ipcMain` to tRPC procedures |
| `router/index.ts` | All tRPC sub-routers merged into `appRouter` |
| `db/index.ts` | Drizzle + better-sqlite3, WAL mode, singleton `getDb()` |
| `db/schema.ts` | Table definitions: clients, projects, tasks, checkpoints, notes, taskComments |
| `db/vectordb.ts` | LanceDB vector store for note embeddings |
| `db/schema.ts` | Table definitions: clients, projects, tasks, checkpoints, notes, noteEdits, taskComments |
| `db/notes-backfill.ts` | Startup backfill: generates aiSummary for notes with null summary |
| `store.ts` | electron-store for persistent UI settings |
### Preload (`src/preload/trpc.ts`)
@@ -57,11 +57,11 @@ Routes: `__root.tsx` (AppShell layout), `index`, `tasks`, `timeline`, `projects`
### tRPC Routers
`health`, `settings`, `clients`, `projects`, `tasks`, `checkpoints`, `notes`, `taskComments`, `ai`
`health`, `settings`, `clients`, `projects`, `tasks`, `checkpoints`, `notes`, `noteEdits`, `taskComments`, `ai`
### Database
Schema in `src/main/db/schema.ts`, migrations in `src/main/db/migrations/`. DB created in Electron's `userData` as `adiuva.db`. On startup, `initDb()` runs non-destructive migrations.
Schema in `src/main/db/schema.ts`, migrations in `src/main/db/migrations/`. DB created in Electron's `userData` as `adiuvai.db`. On startup, `initDb()` runs non-destructive migrations.
To add a table/column: edit `schema.ts``drizzle-kit generate``drizzle-kit push` (dev) or commit the migration.
@@ -83,7 +83,7 @@ Classifies user intent → routes to a specialist agent:
| Agent | Scope | Tools |
|---|---|---|
| Project | Project-scoped Q&A | `read_project_notes`, `add_task`, `get_summary`, `suggest_checkpoints`, `suggest_tasks` |
| Knowledge | Cross-project search | `vector_search_all` |
| Knowledge | Cross-project search | `list_notes` + `get_note` (aiSummary-based navigation) |
| General | Workspace-wide | `add_task` |
All providers use LangChain `bindTools()` + ToolMessage loop (max 5 iterations).
@@ -104,22 +104,26 @@ Also exports `dailyBrief()` for AI-generated daily summaries (`ai.dailyBrief` tR
All use `temperature: 0.3`, streaming enabled. Provider management in `provider.ts`.
### Vector Embeddings (`db/vectordb.ts`)
**Provider factory** (`llm.ts`): `gpt-4o-mini` (OpenAI), `claude-sonnet-4-20250514` (Anthropic), or ChatCopilot wrapper — all with `temperature: 0.3` and streaming enabled.
**Token storage** (`token.ts`) — two-tier fallback:
1. electron-store + `safeStorage` — encrypted at rest (preferred)
2. Plain electron-store — last resort (e.g. WSL with no keyring)
**AI approval pattern**: Tasks and checkpoints have `isAiSuggested` (bool) and `isApproved` (bool) columns. AI-suggested items appear in the UI pending user approval before being treated as real records.
### Notes AI Navigation (aiSummary index)
### Vector Embeddings (`src/main/db/vectordb.ts`)
Notes have `aiSummary` (≤250 char, nullable) and `aiSummaryUpdatedAt` columns. Generated by backend `POST /api/v1/scouts/notes/summarize` (gpt-4o-mini, Langfuse `note_summary` prompt).
LanceDB in `{userData}/vectors/`. Schema: `{ id, projectId, content, vector }` (1536-dim, `text-embedding-3-small` via `embeddings.ts`). Embedding priority: Copilot CLI token → OpenAI token.
- `list_notes` tool output includes the summary per note so AI can navigate without reading full content.
- `notes-backfill.ts` generates missing summaries on startup (throttled 1 req/s, skipped when offline).
- Summary is regenerated fire-and-forget on note create/update and on HITL approve.
- `upsertNoteEmbedding()` on note create/update (fire-and-forget)
- `migrateNotesIfNeeded()` backfills on first startup
- `searchNotes(query, limit=5)` used by Knowledge agent
### Notes HITL (`noteEdits` table)
AI-proposed note edits go to `noteEdits` instead of directly modifying `notes.content`:
- `type: append | insert | replace` — append adds at end; insert after `anchorBefore` text; replace replaces `anchorText`.
- `status: pending | approved | rejected` — pending shows in UI with dashed border + Approve/Reject.
- On approve: content merged into `notes.content`; summary regenerated. If anchor not found (note edited since proposal), auto-rejects.
- `propose_note_edit` backend tool → drizzle-executor `propose_note_edit` case → inserts `noteEdits` row.
- `noteEditsRouter` in `router/index.ts`: `list`, `listPending`, `approve`, `reject`.
### AI Approval Pattern
@@ -136,31 +140,34 @@ Tasks and checkpoints have `isAiSuggested` + `isApproved` columns. AI suggestion
## Design Context
### Target User
Freelancers and solo professionals managing client work (projects, tasks, notes, timelines). Single workspace, no enterprise overhead. AI as force multiplier.
### Users
Freelancers and solo professionals managing client work (projects, tasks, notes, timelines). Single workspace, no enterprise overhead. AI as force multiplier. They open the app mid-workday — often stressed — so the interface must feel immediately grounding and in control.
### Brand
**Calm, intelligent, warm.** Thoughtful companion, not flashy tool. Confident and understated, never loud or gamified.
### Brand Personality
**Calm. Intelligent. Warm.** A thoughtful companion, not a flashy tool. Confident and understated never loud, gamified, or corporate. Fully original aesthetic (no external design system references; this look is intentional and owned).
### Palette
### Emotional Goal
When a user opens AdiuvAI, the first impression should communicate **"everything is under control"** — calm clarity over urgency. The design should lower cognitive load, not raise it.
| | Canvas | Primary | Secondary | Borders |
|---|---|---|---|---|
| **Light** | Pinkish-white `#f4edf3` | Golden yellow `#fbc881` | Slate blue-gray `#8a8ea9` | Dusty lavender `#c8c3cd` |
| **Dark** | Near-black `#0c0c0c` | Pure white | — | Dark gray `#323232` |
### Typography
Geist sans-serif, weights 400/500/600. Tight tracking (`-1px`) on headings. Body `text-sm`, metadata `text-xs`.
### Visual Language
- 10px border-radius, `rounded-2xl` for chat elements
- Glassmorphism on AI inputs (`backdrop-blur-xl`, transparency)
### Aesthetic Direction
- Light mode: pinkish-white canvas `#f4edf3`, golden yellow primary `#fbc881`, slate blue-gray secondary `#8a8ea9`, dusty lavender borders `#c8c3cd`
- Dark mode: near-black `#0c0c0c`, pure white primary, dark gray `#323232` surfaces
- Geist sans-serif, weights 400/500/600. Tight tracking (`-1px`) on headings. Body `text-sm`, metadata `text-xs`
- 10px border-radius (`rounded-lg`), `rounded-2xl` for chat/AI elements
- Glassmorphism on AI inputs (`backdrop-blur-xl`, transparency, gradient border via padding-box/border-box technique)
- Spring animations (stiffness 400, damping 30), scale-and-fade transitions
- No gamification (badges, streaks, confetti). Mature and professional
- Dashed borders + Sparkles icon = AI-pending state marker
### Accessibility
Best-effort — not formally audited. Maintain reasonable contrast and keyboard operability without targeting a specific WCAG level.
### Current Design Focus
**Polish and refinement.** The overall direction is solid; the priority is elevating specific areas that feel rough or inconsistent — tighter spacing, more intentional hierarchy, better empty/loading states, and smoother motion.
### Design Principles
1. **Clarity over cleverness** — Clear hierarchy, generous whitespace, comfortable density
2. **AI as quiet partner** — Deeply integrated but never intrusive. Dashed borders for pending AI items, Sparkles icon as AI marker
3. **Warmth in restraint**Warm palette feels approachable without being playful. Dark mode trades warmth for focus
4. **Motion with purpose**Animations reinforce spatial relationships, never decorative
5. **Confidence through consistency** — CSS variable tokens, shadcn/ui primitives, Geist font. Predictable, keyboard-first
1. **Clarity over cleverness** — Clear hierarchy, generous whitespace, comfortable density. Never sacrifice legibility for style.
2. **AI as quiet partner** — Deeply integrated but never intrusive. Dashed borders for pending AI items, Sparkles icon as the sole AI marker. Surface AI capabilities without making them the hero.
3. **Warmth in restraint**The warm palette feels approachable without being playful. Dark mode trades warmth for focus. Neither mode should feel cold or aggressive.
4. **Motion with purpose**Spring animations reinforce spatial relationships and acknowledge state changes. Never purely decorative. Respect reduced-motion preferences where possible.
5. **Polish over features** — Every surface should feel considered. Prefer refining what exists over introducing new complexity. The right amount of visual weight is the minimum needed.

View File

@@ -2,7 +2,13 @@
"permissions": {
"allow": [
"Bash(git add AI_REFACTOR_PLAN.md)",
"Bash(git commit:*)"
"Bash(git commit:*)",
"Read(//home/rmusso/adiuvai-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/)",
"WebFetch(domain:ui.shadcn.com)"
]
}
}

View File

@@ -8,6 +8,7 @@
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended",
"plugin:import/recommended",
"plugin:import/electron",
"plugin:import/typescript"

10
.gitignore vendored
View File

@@ -91,6 +91,16 @@ typings/
# Electron-Forge
out/
# Web SPA build
dist-web/
# local config files
.vscode/
.agents/
src/renderer/routeTree.gen.ts
# Local dev SQLite (used by drizzle-kit push for schema verification)
dev.db
dev.db-journal
dev.db-shm
dev.db-wal

View File

@@ -1,11 +0,0 @@
{
"mcpServers": {
"shadcn": {
"command": "npx",
"args": [
"shadcn@latest",
"mcp"
]
}
}
}

View File

@@ -1,306 +0,0 @@
# AI Refactor Plan — Adiuva Electron App
> **Objective:** Transform the Electron app into a backend-powered client. All AI intelligence (chat, tool calling, embeddings) lives on the backend. The Electron app owns the local database, executes structured CRUD operations from backend tools via Drizzle ORM, and handles auth, backup, and offline graceful degradation.
>
> **Backend:** Lives at `../adiuva-api/`. FastAPI + LiteLLM + 4 chat agents (task, checkpoint, project, note). Backend plan: `../adiuva-api/AI_REFACTOR_PLAN.md`.
>
> **Protocol:** Execute steps sequentially. Each step is atomic and committable. Mark `[x]` when done.
---
## Architecture
```
Renderer (React 19) ──ipcLink──► Main (tRPC + SQLite) ──HTTP/WS──► Backend (FastAPI + LiteLLM)
UI only Data + Drizzle executor All AI intelligence
```
**Data flow for chat (bidirectional WebSocket):**
1. User types message in renderer → tRPC `ai.chat` mutation
2. Main process builds `ChatContext` (queries SQLite for tasks, notes, profile)
3. Main opens WS to backend `/api/v1/chat/stream?token=<jwt>`, sends `chat_request` frame
4. Backend classifies intent → routes to agent → agent calls LLM with tools
5. LLM calls a tool (e.g. `list_tasks`) → tool calls `execute_on_client()`:
- Backend sends `tool_call` frame: `{id, action:"select", table:"tasks", filters:{...}}`
- Electron receives frame → Drizzle executor: `db.select().from(tasks).where(...)` → real rows
- Electron sends `tool_result` frame: `{id, rows: [{id, title, ...}, ...]}`
- Tool receives real data → returns formatted string to LLM
6. Steps 5 repeats (max 5 iterations) until LLM has enough data to respond
7. Backend streams response text → `text_chunk` frames → main forwards via `ai:stream` IPC → renderer
8. Backend sends `final` frame: `{"done": true, "response": "..."}`
**No local LLM.** When offline, AI features show "You're offline" — all other features (tasks, notes, projects) work normally.
---
## WS Protocol — Typed Frames
| Direction | `type` | Payload |
|---|---|---|
| Client → Server | `chat_request` | `{ message, context }` |
| Server → Client | `text_chunk` | `{ text: string }` |
| Server → Client | `tool_call` | `{ id, action, table?, data?, filters?, vector?, limit? }` |
| Client → Server | `tool_result` | `{ id, row?, rows?, results?, deleted?, ok?, error? }` |
| Server → Client | `final` | `{ response: string }` |
| Server → Client | `ping` | `{}` |
**Tool call actions (Electron → Drizzle mapping):**
| `action` | Drizzle call | Returns |
|---|---|---|
| `select` | `db.select().from(table).where(filters).all()` | `{ rows: [...] }` |
| `get` | `db.select().from(table).where(eq(id, ...)).get()` | `{ row: {...} \| null }` |
| `insert` | `db.insert(table).values({id: uuid(), ...data, createdAt: now()}).returning().get()` | `{ row: {...} }` |
| `update` | `db.update(table).set(data.updates).where(eq(id,...)).returning().get()` | `{ row: {...} }` |
| `delete` | `db.delete(table).where(eq(id,...)).run()` | `{ deleted: true }` |
| `vector_upsert` | LanceDB delete-then-add with pre-computed vector | `{ ok: true }` |
| `vector_search` | LanceDB `table.search(vector).limit(n)` | `{ results: [{id, content, score}...] }` |
Electron generates `id` (UUID v4) and `createdAt`/`updatedAt` (Unix ms) for inserts. Backend never generates IDs.
---
## Phase 0 — API Contracts & Types ✅
### Step 0.1 — Define backend API contract types ✅
- [x] Create `src/shared/api-types.ts` with Zod schemas + inferred types
- [x] Create `src/shared/batch-types.ts` with batch builder + storage types
- [x] Update `tsconfig.json` paths — added `@shared/*` alias
- **Outcome:** Type-safe contracts for all backend communication.
---
## Phase 1 — Auth & Backend Client
### Step 1.1 — Align shared types with backend schemas
- [x] Update `src/shared/api-types.ts` to match backend `app/schemas.py` exactly:
- `AuthTokens.expiresAt`: change from `z.string().datetime()` to `z.number().int()` (Unix epoch)
- `ChatContext`: replace with backend's flat structure — `{ userProfile, relevantDocuments, recentTasks, conversationHistory }`; remove UI-only fields (`type`, `projectId`, `uiContext`)
- Remove `PlanAction` entirely — no more action descriptors
- `ChatResponse`: just `{ response: string }` — no `actions` array
- Align `PlanStep` / `ExecutionPlan` with backend or remove if plan mode is deferred
- [x] Add WebSocket frame Zod schemas:
- `ToolCallAction` enum: `select`, `get`, `insert`, `update`, `delete`, `vector_upsert`, `vector_search`
- `WsToolCall`: `{ type: "tool_call", id: string, action, table?, data?, filters?, vector?, limit? }`
- `WsToolResult`: `{ type: "tool_result", id: string, row?, rows?, results?, deleted?, ok?, error? }`
- `WsTextChunk`, `WsFinal`, `WsPing`, `WsChatRequest`
- `WsServerFrame` / `WsClientFrame` discriminated unions
- [x] Create `src/shared/casing.ts`:
- `toSnakeCase(obj)` — deep-converts camelCase keys to snake_case (outgoing)
- `toCamelCase(obj)` — deep-converts snake_case keys to camelCase (incoming)
- [x] Create `UIChatContext` type in `src/renderer/hooks/useAIChat.ts` for renderer-only fields
- **Files:** `src/shared/api-types.ts`, `src/shared/casing.ts`, `src/renderer/hooks/useAIChat.ts`
- **Outcome:** Shared types match the live backend 1:1. WS frames are fully typed.
### Step 1.2 — Auth manager + tRPC procedures
- [x] Create `src/main/auth/auth-manager.ts`:
- `AuthManager` class (singleton):
- `register(email, password): Promise<AuthTokens>` — POST `/api/v1/auth/register`
- `login(email, password): Promise<AuthTokens>` — POST `/api/v1/auth/login`
- `logout(): void` — clears stored tokens
- `getAccessToken(): string | null` — current JWT
- `refreshToken(): Promise<void>` — POST `/api/v1/auth/refresh`
- `isAuthenticated(): boolean`
- `getProfile(): Promise<UserProfile>` — GET `/api/v1/auth/me`
- Token storage: reuse `src/main/ai/token.ts` (`safeStorage` + electron-store fallback)
- Auto-refresh: check token expiry on every `getAccessToken()` call; if < 5 min remaining, refresh in background
- [x] Add `authRouter` tRPC sub-router to `src/main/router/index.ts`
- [x] Update `src/main/store.ts`: add `backendUrl: string`
- **Files:** `src/main/auth/auth-manager.ts`, `src/main/router/index.ts`, `src/main/store.ts`
- **Outcome:** Electron can authenticate with the backend. JWTs stored securely.
### Step 1.3 — Backend client with bidirectional WebSocket
- [x] Create `src/main/api/backend-client.ts`:
- `BackendClient` class (singleton):
- Constructor: reads `backendUrl` from store, gets JWT from `AuthManager`
- `chatStream(request: ChatRequest, onChunk: (text: string) => void): Promise<ChatResponse>`:
1. Opens WS to `/api/v1/chat/stream?token=<jwt>`
2. Sends `{ type: "chat_request", ... }` frame
3. Message loop:
- `text_chunk` calls `onChunk(text)`
- `tool_call` calls `DrizzleExecutor.execute(payload)`, sends back `{ type: "tool_result", id, ... }`
- `final` resolves with `{ response }`
- `ping` ignore
- `isOnline(): Promise<boolean>` GET `/api/v1/health` with 3s timeout
- `embedText(text: string): Promise<number[]>` POST `/api/v1/storage/vectors/embed`
- All requests include `Authorization: Bearer <jwt>` header
- Auto-retry with exponential backoff (max 3 attempts) for non-auth errors
- Response parsing: `toCamelCase()` on all incoming JSON
- Request serialization: `toSnakeCase()` on all outgoing JSON
- Error categorization: 401 `AuthExpiredError`, 429 `RateLimitError`, 5xx `ServerError`, timeout `OfflineError`
- **Files:** `src/main/api/backend-client.ts`
- **Outcome:** Type-safe HTTP + bidirectional WS client. Tool calls handled in the message loop.
### Step 1.4 — Drizzle executor (the dumb Electron layer)
- [x] Create `src/main/api/drizzle-executor.ts`:
- Table registry: map string names Drizzle table objects from `src/main/db/schema.ts`:
```
{ tasks, projects, clients, checkpoints, notes, taskComments }
```
- `execute(payload): Promise<object>` — dispatches on `payload.action`:
- **`select`**: `db.select().from(table)` + build `.where()` from `payload.filters` using Drizzle `eq()`/`and()`/`like()` + optional `.orderBy()` → returns `{ rows }`
- **`get`**: `db.select().from(table).where(eq(table.id, payload.data.id)).get()` → returns `{ row }`
- **`insert`**: `db.insert(table).values({id: crypto.randomUUID(), ...payload.data, createdAt: Date.now()}).returning().get()` → returns `{ row }`
- **`update`**: `db.update(table).set(payload.data.updates).where(eq(table.id, payload.data.id)).returning().get()` → returns `{ row }`
- **`delete`**: `db.delete(table).where(eq(table.id, payload.data.id)).run()` → returns `{ deleted: true }`
- **`vector_upsert`**: calls `upsertWithVector()` from `vectordb.ts` with pre-computed vector → returns `{ ok: true }`
- **`vector_search`**: LanceDB `table.search(payload.vector).limit(payload.limit)` → returns `{ results }`
- Filter builder: maps `{key: value}` objects → Drizzle `and(eq(table[key], value), ...)`. Special cases:
- `null` value → `isNull(table[key])`
- `search` key → `like(table.title, '%value%')` or `like(table.content, '%value%')`
- `orderBy` key → `.orderBy(asc(table[field]))` or `.orderBy(desc(...))`
- `includeArchived: false` → adds `eq(table.status, 'active')` filter
- `dueDateFrom`/`dueDateTo` → `between(table.dueDate, from, to)`
- Security: validate `table` against registry (reject unknown), validate `action` against enum
- Uses `getDb()` from `src/main/db/index.ts` — same Drizzle instance as everywhere else
- **Files:** `src/main/api/drizzle-executor.ts`
- **Outcome:** ~120 lines. Backend sends structured ops, Electron maps to Drizzle. No SQL building.
### Step 1.5 — Refactor orchestrator to delegate to backend
- [ ] Replace `src/main/ai/orchestrator.ts` entirely (996 lines → ~80 lines):
- `orchestrate({ message, context, sender })`:
1. Check `BackendClient.isOnline()` — if offline, return `{ response: '', error: 'You are offline.' }`
2. Check `AuthManager.isAuthenticated()` — if not, return `{ response: '', error: 'Please log in.' }`
3. Build `ChatContext` from local SQLite (userProfile, recentTasks, conversationHistory)
4. Call `BackendClient.chatStream(request, chunk => sendStreamChunk(sender, chunk, false))`
- `tool_call` frames handled inside the WS message loop (Step 1.3)
5. On completion: `sendStreamChunk(sender, '', true)`
- No PlanRunner, no action handling — writes happen mid-conversation via tool calls
- Keep `sendStreamChunk()` IPC helper
- Export `orchestrate()` and `dailyBrief()`
- [ ] Update `aiRouter` in `src/main/router/index.ts`:
- Remove `setToken` mutation and `hasToken` query (replaced by `auth.status`)
- Keep `chat` mutation (same interface) and `dailyBrief`
- [ ] Update `src/renderer/hooks/useAIChat.ts`:
- Replace `ChatContext` with `UIChatContext` (renderer-only type)
- **Files:** `src/main/ai/orchestrator.ts`, `src/main/router/index.ts`, `src/renderer/hooks/useAIChat.ts`
- **Outcome:** ~916 lines removed. Chat works through backend. All tool execution is bidirectional.
### Step 1.6 — Migrate embeddings to backend
- [ ] Update `src/main/db/vectordb.ts`:
- Add `upsertWithVector(noteId, projectId, content, vector)` — takes pre-computed vector, stores in LanceDB
- Update `upsertNoteEmbedding()` → calls `BackendClient.embedText(content)` → `upsertWithVector()`
- Keep `searchNotes()` and `migrateNotesIfNeeded()` (migration will call backend for embeddings)
- If offline: skip embedding (next edit will re-embed when online)
- [ ] Delete `src/main/ai/embeddings.ts`
- **Files:** `src/main/db/vectordb.ts`, `src/main/ai/embeddings.ts` (deleted)
- **Outcome:** Embeddings generated by backend `/vectors/embed`. Local LanceDB for storage + search.
---
## Phase 2 — Remove Local AI Stack
### Step 2.1 — Remove local AI code and dependencies
- [ ] Delete `src/main/ai/llm.ts`, `src/main/ai/chat-copilot.ts`, `src/main/ai/copilot.ts`, `src/main/ai/provider.ts`
- [ ] Remove `import './ai/copilot'` and `initAI()` from `src/main/index.ts`
- [ ] Remove deps: `@langchain/core`, `@langchain/openai`, `@langchain/anthropic`, `@langchain/langgraph`, `@github/copilot-sdk`
- [ ] Clean up `src/main/store.ts` (remove `aiProvider`, `encryptedTokens`)
- [ ] Clean up `vite.main.config.mts` (remove externalized LangChain/Copilot packages)
- **Files:** Multiple deletions in `src/main/ai/`, `package.json`, `src/main/index.ts`, `src/main/store.ts`, `vite.main.config.mts`
- **Outcome:** ~1400 lines removed. No LangChain, no Copilot SDK, no local LLM.
---
## Phase 3 — Local Plugin System & Batch Agents
### Step 3.1 — Plugin manifest system and permission manager
- [ ] Create `src/main/permissions/manifest-validator.ts` + `permission-manager.ts`
- [ ] Add `plugin_permissions` and `plugin_activity_log` tables to schema
- **Outcome:** Granular, opt-in permission system.
### Step 3.2 — Worker pool and batch runner
- [ ] Create `src/main/workers/worker-pool.ts`, `batch-runner.ts`, `plugin-worker.ts`
- [ ] Plugins call `BackendClient` for AI analysis (not local LLM)
- **Outcome:** Isolated plugin execution with backend-powered AI.
### Step 3.3 — Implement batch agent plugins
- [ ] `src/plugins/email-scanner.ts`, `file-watcher.ts`, `calendar-sync.ts`, `browser-agent.ts`
- **Outcome:** Four batch agents using backend for AI.
---
## Phase 4 — Security: E2E Backup & Offline
### Step 4.1 — E2E encrypted backup
- [ ] `src/main/backup/e2e-crypto.ts` + `backup-manager.ts`
- **Outcome:** User data never leaves the device unencrypted.
### Step 4.2 — Offline sync queue
- [ ] `src/main/backup/sync-queue.ts` + `sync_queue` table
- **Outcome:** Queued actions auto-sync when online.
### Step 4.3 — Migrate to SQLCipher
- [ ] Replace `better-sqlite3` with `@journeyapps/sqlcipher`
- **Outcome:** All local data encrypted at rest.
---
## Phase 5 — Shared Memory
### Step 5.1 — Three-tier local memory
- [ ] `src/main/database/shared-memory.ts`: conversation buffer + agent KV store + multi-collection vector store
- [ ] `agent_memory` table in schema
- **Outcome:** Short-term, long-term, and semantic memory.
---
## 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.2 — ChatPage with context panel
- [ ] `ChatPage.tsx`, `ChatWindow.tsx`, `MessageBubble.tsx`, `ContextPanel.tsx`
### Step 6.3 — BatchBuilderPage
- [ ] Natural language input, config preview, connector/storage/schedule pickers, batch cards, test runner
### Step 6.4 — PluginStorePage
- [ ] Marketplace + installed tabs, permission dialog on install
### Step 6.5 — DataManagerPage
- [ ] Storage overview, per-source cards, migration wizard
### Step 6.6 — ActivityLogPage
- [ ] Filterable activity table with CSV export
---
## Phase 7 — Cleanup & Hardening
### Step 7.1 — Error handling and logging
### Step 7.2 — Integration tests
---
## Dependencies to Add
| Package | Purpose |
|---|---|
| `ws` | WebSocket client for backend streaming |
| `@journeyapps/sqlcipher` | Encrypted SQLite (replaces `better-sqlite3`) |
| `argon2` | Key derivation for E2E backup |
| `node-cron` | Batch agent scheduling |
| `chokidar` | File watching (plugin) |
| `imapflow` | IMAP client (plugin) |
## Dependencies to Remove
| Package | Reason |
|---|---|
| `@langchain/core` | No local LLM |
| `@langchain/openai` | No local LLM |
| `@langchain/anthropic` | No local LLM |
| `@langchain/langgraph` | No local orchestrator |
| `@github/copilot-sdk` | No local Copilot |
---
## Execution Notes
- **Phase 1 is the critical path.** Auth + backend client + drizzle executor + orchestrator refactor must land first.
- **Steps 1.11.4 are additive** — existing app keeps working until Step 1.5 swaps the orchestrator.
- **Step 2.1 is the point of no return** — after removing LangChain, there's no local AI fallback.
- **Phase B (backend changes) must land before Phase 1.31.5** — Electron needs the bidirectional WS to talk to.
- **Phase 3 and Phase 4 are independent** — can be parallelized after Phase 2.
- **One step at a time.** Mark `[x]` and commit with `step N.N complete: <outcome>`.

View File

@@ -0,0 +1,385 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>adiuvAI — Brand Identity</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-light: #f4edf3; --bg-dark: #0c0c0c;
--text: #040404; --text-light: #fbfbfb;
--primary: #fbc881; --secondary: #8a8ea9;
--muted: #c8c3cd; --border: #c8c3cd;
--radius: 10px;
}
body { font-family: 'Inter', system-ui, sans-serif; background: var(--bg-light); color: var(--text); -webkit-font-smoothing: antialiased; }
.container { max-width: 1100px; margin: 0 auto; padding: 0 32px; }
.section-label { font-size: 11px; font-weight: 600; letter-spacing: .12em; text-transform: uppercase; color: var(--primary); margin-bottom: 12px; }
/* ── COMPASS ANIMATION ── */
.compass-needle { animation: compass-settle 5s ease-in-out infinite; transform-origin: 32px 32px; }
@keyframes compass-settle {
0% { transform: rotate(0deg); }
20% { transform: rotate(4deg); }
50% { transform: rotate(-3deg); }
80% { transform: rotate(2deg); }
100% { transform: rotate(0deg); }
}
/* ── HERO ── */
.hero { background: var(--bg-dark); padding: 96px 32px 80px; text-align: center; }
.hero-mark { width: 96px; height: 96px; margin: 0 auto 32px; }
.hero-label { font-size: 11px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--primary); margin-bottom: 16px; }
.hero-name { font-size: clamp(40px,6vw,64px); font-weight: 700; color: var(--text-light); letter-spacing: -2px; line-height: 1; margin-bottom: 16px; }
.hero-name span { color: var(--primary); }
.hero-tagline { font-size: 16px; color: rgba(251,251,251,.45); max-width: 420px; margin: 0 auto; line-height: 1.6; }
/* ── SECTIONS ── */
.section { padding: 72px 32px; border-bottom: 1px solid var(--border); }
.section:last-of-type { border-bottom: none; }
.section-dark { background: var(--bg-dark); border-bottom: 1px solid #1a1a1a; }
.section h2 { font-size: 22px; font-weight: 600; color: var(--text); margin-bottom: 8px; letter-spacing: -.5px; }
.section-dark h2 { color: var(--text-light); }
.section > p { font-size: 14px; color: var(--secondary); line-height: 1.7; max-width: 560px; margin-bottom: 40px; }
.section-dark > p { color: rgba(251,251,251,.4); }
/* ── CONCEPT GRID ── */
.concept-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 48px; align-items: start; }
@media (max-width:700px) { .concept-grid { grid-template-columns: 1fr; } }
.concept-mark-wrapper { background: var(--bg-dark); border-radius: var(--radius); padding: 56px 48px; display: flex; justify-content: center; align-items: center; }
.concept-text .point { display: flex; gap: 12px; margin-bottom: 22px; align-items: flex-start; }
.point-icon { width: 26px; height: 26px; background: rgba(251,200,129,.1); border: 1px solid rgba(251,200,129,.22); border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 1px; font-size: 12px; }
.point-title { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 4px; }
.point-desc { font-size: 13px; color: var(--secondary); line-height: 1.65; margin: 0; }
/* ── VARIANTS ── */
.variants-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.variant-full { grid-column: 1 / -1; }
@media (max-width:700px) { .variants-grid { grid-template-columns: 1fr; } }
.logo-card { border-radius: var(--radius); overflow: hidden; border: 1px solid var(--border); transition: transform .18s ease, box-shadow .18s ease; }
.logo-card:hover { transform: translateY(-3px); box-shadow: 0 8px 32px rgba(0,0,0,.10); }
.logo-card-dark { border-color: #1e1e1e; }
.logo-card-dark:hover { box-shadow: 0 8px 32px rgba(0,0,0,.5); }
.card-inner { padding: 40px 32px; display: flex; align-items: center; justify-content: center; min-height: 120px; }
.card-inner-light { background: var(--bg-light); }
.card-inner-dark { background: var(--bg-dark); }
.card-inner-white { background: #fff; }
.card-inner img { max-width: 100%; max-height: 72px; object-fit: contain; }
.card-meta { padding: 10px 16px; display: flex; justify-content: space-between; align-items: center; border-top: 1px solid var(--border); background: #fff; }
.card-meta-dark { border-top-color: #1e1e1e; background: #0e0e0e; }
.card-label { font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--secondary); }
.card-meta-dark .card-label { color: rgba(251,251,251,.3); }
.card-filename { font-size: 11px; font-family: monospace; color: var(--muted); }
.card-meta-dark .card-filename { color: rgba(251,251,251,.2); }
/* ── PALETTE ── */
.palette-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(140px,1fr)); gap: 12px; }
.color-chip { border-radius: var(--radius); overflow: hidden; border: 1px solid rgba(0,0,0,.06); }
.color-swatch { height: 80px; display: flex; align-items: flex-end; padding: 8px 10px; }
.color-hex { font-size: 11px; font-family: monospace; font-weight: 500; opacity: .65; }
.color-info { padding: 10px; background: #fff; }
.color-name { font-size: 12px; font-weight: 600; color: var(--text); margin-bottom: 2px; }
.color-role { font-size: 11px; color: var(--secondary); }
/* ── TYPE ── */
.type-specimen { background: #fff; border: 1px solid var(--border); border-radius: var(--radius); padding: 40px; }
.type-row { padding: 20px 0; border-bottom: 1px solid #f0eaef; }
.type-row:last-child { border-bottom: none; padding-bottom: 0; }
.type-meta { font-size: 11px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); margin-bottom: 8px; }
.type-xl { font-size: 48px; font-weight: 700; letter-spacing: -2px; line-height: 1; }
.type-xl span { color: var(--primary); }
.type-lg { font-size: 28px; font-weight: 600; letter-spacing: -.8px; }
.type-md { font-size: 16px; font-weight: 400; line-height: 1.6; }
.type-sm { font-size: 12px; font-weight: 500; letter-spacing: .06em; text-transform: uppercase; color: var(--secondary); }
/* ── DEV REF ── */
.dev-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
@media (max-width:700px) { .dev-grid { grid-template-columns: 1fr; } }
.code-block { background: #0e0e0e; border: 1px solid #1e1e1e; border-radius: var(--radius); padding: 24px; overflow-x: auto; }
.code-block pre { font-family: monospace; font-size: 12px; line-height: 1.7; color: #e1e2e8; }
.ck { color: #a1c9fd; } .cs { color: #fbc881; } .cc { color: rgba(225,226,232,.28); font-style: italic; }
.file-tree { background: #0e0e0e; border: 1px solid #1e1e1e; border-radius: var(--radius); padding: 24px; }
.file-tree-title { font-size: 11px; font-weight: 600; letter-spacing: .10em; text-transform: uppercase; color: rgba(251,251,251,.28); margin-bottom: 16px; }
.fi { display: flex; gap: 10px; margin-bottom: 10px; align-items: flex-start; }
.fi:last-child { margin-bottom: 0; }
.fi-icon { font-size: 12px; flex-shrink: 0; margin-top: 1px; }
.fi-name { font-family: monospace; font-size: 12px; color: #fbc881; flex-shrink: 0; }
.fi-desc { font-size: 12px; color: rgba(225,226,232,.3); line-height: 1.5; }
/* ── FOOTER ── */
.footer { background: var(--bg-dark); padding: 48px 32px; text-align: center; }
.footer-name { font-size: 20px; font-weight: 700; color: var(--text-light); letter-spacing: -.5px; margin-bottom: 4px; }
.footer-name span { color: var(--primary); }
.footer-sub { font-size: 12px; color: rgba(251,251,251,.28); letter-spacing: .06em; }
</style>
</head>
<body>
<!-- HERO -->
<section class="hero">
<div class="container">
<div class="hero-mark">
<svg viewBox="0 0 64 64" fill="none" width="96" height="96">
<g class="compass-needle">
<path d="M32,4 L48,32 L16,32 Z" fill="#fbc881"/>
<path d="M16,32 L48,32 L32,60 Z" fill="#FFFFFF" opacity="0.75"/>
<circle cx="32" cy="32" r="2.5" fill="#FFFFFF" opacity="0.2"/>
</g>
</svg>
</div>
<p class="hero-label">Brand Identity</p>
<h1 class="hero-name">adiuv<span>AI</span></h1>
<p class="hero-tagline">Il tuo compasso nel lavoro quotidiano — l'AI che ti indica sempre la direzione giusta.</p>
</div>
</section>
<!-- DESIGN CONCEPT -->
<section class="section">
<div class="container">
<p class="section-label">Design Concept</p>
<h2>Il Compasso</h2>
<p>Non il gesto dell'aiuto, ma il suo significato più profondo: qualcuno che ti indica la strada. Un ago di bussola che oscilla e si ferma sempre a nord.</p>
<div class="concept-grid">
<div class="concept-mark-wrapper">
<svg viewBox="0 0 64 64" fill="none" width="140" height="140">
<g class="compass-needle">
<path d="M32,4 L48,32 L16,32 Z" fill="#fbc881"/>
<path d="M16,32 L48,32 L32,60 Z" fill="#FFFFFF" opacity="0.65"/>
<line x1="16" y1="32" x2="48" y2="32" stroke="#fff" stroke-width="0.5" opacity="0.15"/>
<circle cx="32" cy="32" r="2.5" fill="#fff" opacity="0.2"/>
</g>
<!-- Annotations -->
<text x="34" y="14" font-size="4" fill="rgba(251,200,129,.6)" font-family="Inter,sans-serif" letter-spacing=".05em">NORD · AI</text>
<line x1="32" y1="17" x2="32" y2="20" stroke="rgba(251,200,129,.4)" stroke-width=".6"/>
<text x="34" y="52" font-size="4" fill="rgba(255,255,255,.35)" font-family="Inter,sans-serif" letter-spacing=".05em">SUD · YOU</text>
<line x1="32" y1="47" x2="32" y2="50" stroke="rgba(255,255,255,.2)" stroke-width=".6"/>
</svg>
</div>
<div class="concept-text">
<div class="point">
<div class="point-icon"></div>
<div>
<div class="point-title">Nord dorato = l'AI</div>
<p class="point-desc">La punta superiore (#fbc881) punta sempre verso l'alto — verso l'obiettivo. È l'AI: calda, orientata, che guida senza invadere.</p>
</div>
</div>
<div class="point">
<div class="point-icon"></div>
<div>
<div class="point-title">Sud scuro = l'utente</div>
<p class="point-desc">La punta inferiore (#040404) è ancorata alla realtà. L'utente con le sue attività, i suoi progetti, il suo lavoro concreto.</p>
</div>
</div>
<div class="point">
<div class="point-icon"></div>
<div>
<div class="point-title">L'oscillazione (animazione)</div>
<p class="point-desc">Il mark oscilla leggermente come un vero ago prima di fermarsi — trovare il nord. Un dettaglio quasi impercettibile, fedele alla brand personality "calma, mai appariscente".</p>
</div>
</div>
<div class="point">
<div class="point-icon"></div>
<div>
<div class="point-title">Il diamante (forma)</div>
<p class="point-desc">Due triangoli che formano un rombo. Forma archetipica, funziona a 16px come a 512px. La divisione orizzontale racconta la relazione senza bisogno di parole.</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- LOGO VARIANTS -->
<section class="section" style="background:#faf5f9;">
<div class="container">
<p class="section-label">Logo Variants</p>
<h2>7 File Canonici</h2>
<p>Ogni variante usa gli stessi due triangoli — cambiano solo colore e scala.</p>
<div class="variants-grid">
<div class="logo-card variant-full">
<div class="card-inner card-inner-light" style="min-height:100px;">
<img src="logo-full.svg" alt="adiuvAI full logo">
</div>
<div class="card-meta">
<span class="card-label">Full Logo</span>
<span class="card-filename">logo-full.svg</span>
</div>
</div>
<div class="logo-card logo-card-dark variant-full">
<div class="card-inner card-inner-dark" style="min-height:100px;">
<img src="logo-white.svg" alt="adiuvAI white">
</div>
<div class="card-meta card-meta-dark">
<span class="card-label">White Variant</span>
<span class="card-filename">logo-white.svg</span>
</div>
</div>
<div class="logo-card logo-card-dark">
<div class="card-inner card-inner-dark" style="min-height:140px;">
<img src="logo-mark.svg" alt="mark" style="width:80px;height:80px;">
</div>
<div class="card-meta card-meta-dark">
<span class="card-label">Mark</span>
<span class="card-filename">logo-mark.svg</span>
</div>
</div>
<div class="logo-card">
<div class="card-inner card-inner-white" style="min-height:140px;">
<img src="logo-icon.svg" alt="icon" style="max-width:110px;max-height:110px;">
</div>
<div class="card-meta">
<span class="card-label">App Icon</span>
<span class="card-filename">logo-icon.svg</span>
</div>
</div>
<div class="logo-card">
<div class="card-inner card-inner-light">
<img src="logo-wordmark.svg" alt="wordmark" style="max-height:40px;">
</div>
<div class="card-meta">
<span class="card-label">Wordmark</span>
<span class="card-filename">logo-wordmark.svg</span>
</div>
</div>
<div class="logo-card logo-card-dark">
<div class="card-inner card-inner-dark" style="flex-direction:column;gap:8px;">
<img src="favicon.svg" alt="favicon" style="width:64px;height:64px;image-rendering:pixelated;">
<span style="font-size:10px;color:rgba(251,251,251,.25);font-family:monospace;">16×16 px</span>
</div>
<div class="card-meta card-meta-dark">
<span class="card-label">Favicon</span>
<span class="card-filename">favicon.svg</span>
</div>
</div>
<div class="logo-card variant-full">
<div class="card-inner card-inner-white" style="min-height:100px;">
<img src="logo-black.svg" alt="black">
</div>
<div class="card-meta">
<span class="card-label">Black Variant</span>
<span class="card-filename">logo-black.svg</span>
</div>
</div>
</div>
</div>
</section>
<!-- COLOR PALETTE -->
<section class="section">
<div class="container">
<p class="section-label">Color Palette</p>
<h2>Colori Brand</h2>
<p>Estratti direttamente da globals.css. Canvas rosato caldo in light; monocromo rigoroso in dark.</p>
<div class="palette-grid">
<div class="color-chip">
<div class="color-swatch" style="background:#fbc881;"><span class="color-hex" style="color:rgba(4,4,4,.5);">#fbc881</span></div>
<div class="color-info"><div class="color-name">Golden</div><div class="color-role">Nord · AI · Accent</div></div>
</div>
<div class="color-chip">
<div class="color-swatch" style="background:#f4edf3;"><span class="color-hex" style="color:rgba(4,4,4,.35);">#f4edf3</span></div>
<div class="color-info"><div class="color-name">Canvas Light</div><div class="color-role">Sfondo light mode</div></div>
</div>
<div class="color-chip">
<div class="color-swatch" style="background:#0c0c0c;"><span class="color-hex" style="color:rgba(251,251,251,.35);">#0c0c0c</span></div>
<div class="color-info"><div class="color-name">Canvas Dark</div><div class="color-role">Sfondo dark mode</div></div>
</div>
<div class="color-chip">
<div class="color-swatch" style="background:#040404;"><span class="color-hex" style="color:rgba(251,251,251,.35);">#040404</span></div>
<div class="color-info"><div class="color-name">Ink</div><div class="color-role">Sud · utente · testo</div></div>
</div>
<div class="color-chip">
<div class="color-swatch" style="background:#fbfbfb;border:1px solid #e8e0e7;"><span class="color-hex" style="color:rgba(4,4,4,.28);">#fbfbfb</span></div>
<div class="color-info"><div class="color-name">Paper</div><div class="color-role">Testo dark mode</div></div>
</div>
<div class="color-chip">
<div class="color-swatch" style="background:#8a8ea9;"><span class="color-hex" style="color:rgba(251,251,251,.65);">#8a8ea9</span></div>
<div class="color-info"><div class="color-name">Slate</div><div class="color-role">Secondario · muted</div></div>
</div>
<div class="color-chip">
<div class="color-swatch" style="background:#c8c3cd;"><span class="color-hex" style="color:rgba(4,4,4,.38);">#c8c3cd</span></div>
<div class="color-info"><div class="color-name">Lavender</div><div class="color-role">Bordi · muted</div></div>
</div>
<div class="color-chip">
<div class="color-swatch" style="background:#323232;"><span class="color-hex" style="color:rgba(251,251,251,.35);">#323232</span></div>
<div class="color-info"><div class="color-name">Graphite</div><div class="color-role">Dark surfaces</div></div>
</div>
</div>
</div>
</section>
<!-- TYPOGRAPHY -->
<section class="section" style="background:#faf5f9;">
<div class="container">
<p class="section-label">Typography</p>
<h2>Geist · System Sans-Serif</h2>
<p>Geometrico, pulito, sicuro. @fontsource/geist nell'app; system-ui come fallback.</p>
<div class="type-specimen">
<div class="type-row"><div class="type-meta">Display · 48px · 700</div><div class="type-xl">adiuv<span>AI</span></div></div>
<div class="type-row"><div class="type-meta">Heading · 28px · 600</div><div class="type-lg">Workspace intelligente, locale, caldo.</div></div>
<div class="type-row"><div class="type-meta">Body · 16px · 400</div><div class="type-md">adiuvAI organizza i tuoi progetti, task e note in un workspace calmo — con un AI che ti guida silenziosamente verso gli obiettivi.</div></div>
<div class="type-row"><div class="type-meta">Label · 11px · 600 · Tracked</div><div class="type-sm">Brand Identity · Design System · 2026</div></div>
</div>
</div>
</section>
<!-- DEVELOPER REFERENCE -->
<section class="section-dark section">
<div class="container">
<p class="section-label">Developer Reference</p>
<h2>Tailwind Config · File Tree</h2>
<p>Token da aggiungere al blocco @theme di globals.css (Tailwind 4).</p>
<div class="dev-grid">
<div class="code-block">
<pre><span class="cc">// globals.css — @theme inline</span>
<span class="ck">--brand-golden</span>: <span class="cs">#fbc881</span>; <span class="cc">/* nord · AI */</span>
<span class="ck">--brand-canvas</span>: <span class="cs">#f4edf3</span>; <span class="cc">/* light bg */</span>
<span class="ck">--brand-void</span>: <span class="cs">#0c0c0c</span>; <span class="cc">/* dark bg */</span>
<span class="ck">--brand-ink</span>: <span class="cs">#040404</span>; <span class="cc">/* sud · user */</span>
<span class="ck">--brand-slate</span>: <span class="cs">#8a8ea9</span>; <span class="cc">/* secondary */</span>
<span class="ck">--brand-lavender</span>: <span class="cs">#c8c3cd</span>; <span class="cc">/* border */</span>
<span class="ck">--brand-graphite</span>: <span class="cs">#323232</span>; <span class="cc">/* dark surface */</span>
<span class="ck">--brand-paper</span>: <span class="cs">#fbfbfb</span>; <span class="cc">/* light text */</span></pre>
</div>
<div class="file-tree">
<div class="file-tree-title">assets/logo/</div>
<div class="fi"><span class="fi-icon"></span><span class="fi-name">logo-mark.svg</span><span class="fi-desc">Compasso canonico · 64×64 · animato</span></div>
<div class="fi"><span class="fi-icon"></span><span class="fi-name">logo-full.svg</span><span class="fi-desc">Mark + wordmark · 320×72 · animato</span></div>
<div class="fi"><span class="fi-icon">T</span><span class="fi-name">logo-wordmark.svg</span><span class="fi-desc">Solo testo · 200×40</span></div>
<div class="fi"><span class="fi-icon"></span><span class="fi-name">logo-icon.svg</span><span class="fi-desc">App icon · 512×512</span></div>
<div class="fi"><span class="fi-icon">·</span><span class="fi-name">favicon.svg</span><span class="fi-desc">Semplificato · 16×16</span></div>
<div class="fi"><span class="fi-icon"></span><span class="fi-name">logo-white.svg</span><span class="fi-desc">Variante bianca · su sfondo scuro</span></div>
<div class="fi"><span class="fi-icon"></span><span class="fi-name">logo-black.svg</span><span class="fi-desc">Variante nera · su sfondo chiaro</span></div>
<div class="fi" style="margin-top:12px;padding-top:12px;border-top:1px solid #1e1e1e;">
<span class="fi-icon">🌐</span><span class="fi-name">brand-showcase.html</span><span class="fi-desc">Questa pagina</span>
</div>
</div>
</div>
</div>
</section>
<footer class="footer">
<div class="container">
<div class="footer-name">adiuv<span>AI</span></div>
<div class="footer-sub">Brand Identity · roberto · 2026</div>
</div>
</footer>
</body>
</html>

10
assets/logo/favicon.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
<!--
adiuvAI — Favicon 16×16
Same compass needle, scaled to canvas.
North: M8,1 L13,8 L3,8 Z
South: M3,8 L13,8 L8,15 Z
-->
<path d="M8,1 L13,8 L3,8 Z" fill="#fbc881"/>
<path d="M3,8 L13,8 L8,15 Z" fill="#040404"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 70" fill="none">
<!-- adiuvAI — Black variant (light backgrounds, no color) -->
<g transform="translate(2,2)">
<path d="M32,4 L48,32 L16,32 Z" fill="#1A1A1A" opacity="0.55"/>
<path d="M16,32 L48,32 L32,60 Z" fill="#1A1A1A"/>
<circle cx="32" cy="32" r="2.5" fill="#1A1A1A" opacity="0.2"/>
</g>
<text x="65" y="42"
font-family="Geist, system-ui, -apple-system, sans-serif"
font-size="30" letter-spacing="-0.5">
<tspan font-weight="400" fill="#1A1A1A" opacity="0.7">adiuv</tspan><tspan font-weight="700" fill="#1A1A1A">AI</tspan>
</text>
</svg>

After

Width:  |  Height:  |  Size: 646 B

35
assets/logo/logo-full.svg Normal file
View File

@@ -0,0 +1,35 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 70" fill="none">
<!--
adiuvAI — Full logo (mark + wordmark)
Mark: translate(4,4) — canonical paths from logo-mark.svg
-->
<style>
.compass-needle {
animation: compass-settle 5s ease-in-out infinite;
transform-origin: 32px 32px;
}
@keyframes compass-settle {
0% { transform: rotate(0deg); }
20% { transform: rotate(4deg); }
50% { transform: rotate(-3deg); }
80% { transform: rotate(2deg); }
100% { transform: rotate(0deg); }
}
</style>
<g transform="translate(2,2)">
<g class="compass-needle">
<path d="M32,4 L48,32 L16,32 Z" fill="#fbc881"/>
<path d="M16,32 L48,32 L32,60 Z" fill="#040404"/>
<line x1="16" y1="32" x2="48" y2="32"
stroke="#040404" stroke-width="0.5" opacity="0.12"/>
<circle cx="32" cy="32" r="2.5" fill="#040404" opacity="0.18"/>
</g>
</g>
<text x="65" y="42"
font-family="Geist, system-ui, -apple-system, sans-serif"
font-size="30" letter-spacing="-0.5">
<tspan font-weight="400" fill="#040404">adiuv</tspan><tspan font-weight="700" fill="#fbc881">AI</tspan>
</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
assets/logo/logo-icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

BIN
assets/logo/logo-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

14
assets/logo/logo-icon.svg Normal file
View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!--
adiuvAI — App icon 512×512
Mark scaled 6.5× — translate(48,48) scale(6.5)
-->
<rect width="512" height="512" rx="112" fill="#f4edf3"/>
<g transform="translate(48,48) scale(6.5)">
<path d="M32,4 L48,32 L16,32 Z" fill="#fbc881"/>
<path d="M16,32 L48,32 L32,60 Z" fill="#040404"/>
<line x1="16" y1="32" x2="48" y2="32"
stroke="#040404" stroke-width="0.5" opacity="0.12"/>
<circle cx="32" cy="32" r="2.5" fill="#040404" opacity="0.18"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 564 B

41
assets/logo/logo-mark.svg Normal file
View File

@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<!--
adiuvAI — "Il Compasso" (The Compass Needle)
A compass needle split at its equator:
North (top) → golden = the AI, always pointing toward your goal
South (bottom) → dark = the user, grounded in reality
CANONICAL PATHS (derive all variants from these):
North: M32,4 L48,32 L16,32 Z
South: M16,32 L48,32 L32,60 Z
Center: line x1=16 y1=32 x2=48 y2=32 (1px hairline separator)
The shape oscillates like a compass finding north — settles on upward guidance.
-->
<style>
.compass-needle {
animation: compass-settle 5s ease-in-out infinite;
transform-origin: 32px 32px;
}
@keyframes compass-settle {
0% { transform: rotate(0deg); }
20% { transform: rotate(4deg); }
50% { transform: rotate(-3deg); }
80% { transform: rotate(2deg); }
100% { transform: rotate(0deg); }
}
</style>
<g class="compass-needle">
<!-- North — AI (golden) -->
<path d="M32,4 L48,32 L16,32 Z" fill="#fbc881"/>
<!-- South — Human (dark) -->
<path d="M16,32 L48,32 L32,60 Z" fill="#040404"/>
<!-- Hairline equator -->
<line x1="16" y1="32" x2="48" y2="32"
stroke="#040404" stroke-width="0.5" opacity="0.12"/>
<!-- Center pivot -->
<circle cx="32" cy="32" r="2.5" fill="#040404" opacity="0.18"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 70" fill="none">
<!-- adiuvAI — White variant (dark backgrounds) -->
<g transform="translate(2,2)">
<path d="M32,4 L48,32 L16,32 Z" fill="#fbc881"/>
<path d="M16,32 L48,32 L32,60 Z" fill="#FFFFFF" opacity="0.85"/>
<circle cx="32" cy="32" r="2.5" fill="#FFFFFF" opacity="0.25"/>
</g>
<text x="65" y="42"
font-family="Geist, system-ui, -apple-system, sans-serif"
font-size="30" letter-spacing="-0.5">
<tspan font-weight="400" fill="#FFFFFF" opacity="0.85">adiuv</tspan><tspan font-weight="700" fill="#FFFFFF">AI</tspan>
</text>
</svg>

After

Width:  |  Height:  |  Size: 637 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 105 30" fill="none">
<text x="2" y="25"
font-family="Geist, system-ui, -apple-system, sans-serif"
font-size="30" letter-spacing="-0.5">
<tspan font-weight="400" fill="#040404">adiuv</tspan><tspan font-weight="700" fill="#fbc881">AI</tspan>
</text>
</svg>

After

Width:  |  Height:  |  Size: 332 B

BIN
assets/screenshot/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

BIN
assets/screenshot/task.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

View File

@@ -1,7 +1,11 @@
import { defineConfig } from 'drizzle-kit';
import path from 'path';
export default defineConfig({
schema: './src/main/db/schema.ts',
out: './src/main/db/migrations',
dialect: 'sqlite',
dbCredentials: {
url: `file:${path.resolve('./dev.db')}`,
},
});

View File

@@ -15,12 +15,8 @@ import { execSync } from 'node:child_process';
// Keep this list in sync with the Vite external array.
const externalPackages = [
'better-sqlite3',
'@github/copilot-sdk',
'@langchain/core',
'@langchain/langgraph',
'@langchain/openai',
'@langchain/anthropic',
'vectordb',
'@lancedb/lancedb',
'ws',
'electron-squirrel-startup',
'electron-store',
];
@@ -30,7 +26,23 @@ const config: ForgeConfig = {
asar: {
unpack: '**/{*.node,*.dll,*.so,*.dylib}',
},
name: 'adiuva',
name: 'adiuvAI',
// icon path without extension — Forge picks .ico (Win), .icns (Mac), .png (Linux)
icon: 'assets/logo/logo-icon',
// Ship Drizzle's generated migrations as a sibling of the asar so the
// runtime migrator (drizzle-orm/better-sqlite3/migrator) can read them at
// `<resourcesPath>/migrations/` in packaged builds. See src/main/db/index.ts.
extraResource: ['./src/main/db/migrations'],
// Deep-link protocol for OAuth callback: adiuvai://oauth/callback?code=...
// macOS: written into Info.plist by Forge automatically.
// Windows: registered by the Squirrel installer via packagerConfig.protocols.
// Dev: app.setAsDefaultProtocolClient() in index.ts handles both platforms.
protocols: [
{
name: 'AdiuvAI',
schemes: ['adiuvai'],
},
],
},
rebuildConfig: {},
hooks: {
@@ -70,20 +82,20 @@ const config: ForgeConfig = {
const targetKey = `${platform}-${arch}`;
// vectordb uses platform-specific optional deps (@lancedb/vectordb-<platform>-<arch>-*).
// @lancedb/lancedb uses platform-specific optional deps (@lancedb/lancedb-<platform>-<arch>-*).
// npm install on Linux only pulls the Linux variant. Force-install the target's.
const platformNativePackages: Record<string, Record<string, string>> = {
'win32-x64': {
'@lancedb/vectordb-win32-x64-msvc': '',
'@lancedb/lancedb-win32-x64-msvc': '',
},
'linux-x64': {
'@lancedb/vectordb-linux-x64-gnu': '',
'@lancedb/lancedb-linux-x64-gnu': '',
},
'darwin-x64': {
'@lancedb/vectordb-darwin-x64': '',
'@lancedb/lancedb-darwin-x64': '',
},
'darwin-arm64': {
'@lancedb/vectordb-darwin-arm64': '',
'@lancedb/lancedb-darwin-arm64': '',
},
};
const nativePkgs = platformNativePackages[targetKey];
@@ -92,7 +104,7 @@ const config: ForgeConfig = {
const nmPath = path.join(buildPath, 'node_modules', '@lancedb');
if (fs.existsSync(nmPath)) {
for (const entry of fs.readdirSync(nmPath)) {
if (entry.startsWith('vectordb-') && !Object.keys(nativePkgs).includes(`@lancedb/${entry}`)) {
if (entry.startsWith('lancedb-') && !Object.keys(nativePkgs).includes(`@lancedb/${entry}`)) {
fs.rmSync(path.join(nmPath, entry), { recursive: true, force: true });
console.log(`[forge] Removed non-target native package: @lancedb/${entry}`);
}
@@ -108,8 +120,7 @@ const config: ForgeConfig = {
}
// Remove cross-platform prebuilt binaries that don't match the target.
// Packages like @github/copilot ship prebuilds for all platforms;
// keeping foreign-arch .node files breaks rpmbuild's strip step.
// Keeping foreign-arch .node files breaks rpmbuild's strip step.
const nodeModulesPath = path.join(buildPath, 'node_modules');
const findPrebuilds = (dir: string): string[] => {
const results: string[] = [];
@@ -137,26 +148,6 @@ const config: ForgeConfig = {
}
}
// @github/copilot ships @teddyzhu/clipboard-* platform packages outside
// of prebuilds/. Remove non-target variants to avoid bundling wrong binaries.
const clipboardDir = path.join(buildPath, 'node_modules', '@github', 'copilot', 'clipboard', 'node_modules', '@teddyzhu');
if (fs.existsSync(clipboardDir)) {
const targetClipboardMap: Record<string, string> = {
'win32-x64': 'clipboard-win32-x64-msvc',
'win32-arm64': 'clipboard-win32-arm64-msvc',
'linux-x64': 'clipboard-linux-x64-gnu',
'linux-arm64': 'clipboard-linux-arm64-gnu',
'darwin-x64': 'clipboard-darwin-x64',
'darwin-arm64': 'clipboard-darwin-arm64',
};
const wantedPkg = targetClipboardMap[targetKey];
for (const entry of fs.readdirSync(clipboardDir)) {
if (entry.startsWith('clipboard-') && entry !== wantedPkg) {
fs.rmSync(path.join(clipboardDir, entry), { recursive: true, force: true });
console.log(`[forge] Removed non-target clipboard package: @teddyzhu/${entry}`);
}
}
}
},
// ── Post-rebuild: fix native binaries for cross-compilation ──────

View File

@@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Adiuva</title>
<title>adiuvAI</title>
<link rel="icon" type="image/svg+xml" href="/logo/favicon.svg" />
</head>
<body>
<div id="root"></div>

2046
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "adiuva",
"productName": "Adiuva",
"name": "adiuvai",
"productName": "adiuvAI",
"version": "0.1.0",
"description": "Local-first intelligent desktop workspace",
"main": ".vite/build/main.js",
@@ -11,7 +11,10 @@
"make": "electron-forge make",
"publish": "electron-forge publish",
"lint": "eslint --ext .ts,.tsx .",
"knip": "knip"
"knip": "knip",
"dev:web": "vite --config vite.web.config.mts",
"build:web": "vite build --config vite.web.config.mts",
"preview:web": "vite preview --config vite.web.config.mts"
},
"keywords": [],
"author": "roberto",
@@ -40,21 +43,17 @@
"eslint": "^8.57.1",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-react-hooks": "^4.6.2",
"knip": "^5.85.0",
"postcss": "^8.5.6",
"shadcn": "^3.8.5",
"shadcn": "^4.0.8",
"tailwindcss": "^4.2.0",
"typescript": "^5.9.3",
"vite": "^5.4.21"
},
"dependencies": {
"@fontsource/geist": "^5.2.8",
"@github/copilot-sdk": "^0.1.25",
"@hello-pangea/dnd": "^18.0.1",
"@langchain/anthropic": "^1.3.19",
"@langchain/core": "^1.1.27",
"@langchain/langgraph": "^1.1.5",
"@langchain/openai": "^1.2.9",
"@milkdown/crepe": "^7.18.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.0",
@@ -72,16 +71,24 @@
"electron-squirrel-startup": "^1.0.1",
"electron-store": "^8.2.0",
"framer-motion": "^12.34.2",
"i18next": "^26.0.4",
"lucide-react": "^0.575.0",
"mammoth": "^1.11.0",
"next-themes": "^0.4.6",
"pdf-parse": "^2.4.5",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-day-picker": "^9.13.2",
"react-dom": "^19.2.4",
"react-easy-crop": "^5.5.7",
"react-i18next": "^17.0.2",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.11.0",
"recharts": "^2.15.4",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"vectordb": "^0.21.2",
"ws": "^8.19.0",
"zod": "^4.3.6"
}

179
scripts/seed-fake-data.py Normal file
View File

@@ -0,0 +1,179 @@
"""
Seed script: inserts fake clients, projects, tasks, timeline events, and notes
into the local adiuvAI SQLite database.
Usage: python scripts/seed-fake-data.py
"""
import os
import sqlite3
import uuid
import random
import time
# ── locate the database ──────────────────────────────────────────────────
appdata = os.environ.get("APPDATA")
if not appdata:
raise RuntimeError("APPDATA environment variable not found (Windows only)")
db_path = os.path.join(appdata, "adiuvAI", "adiuvai.db")
if not os.path.isfile(db_path):
raise FileNotFoundError(f"Database not found at {db_path}. Is the app installed / run at least once?")
print(f"Using database: {db_path}")
conn = sqlite3.connect(db_path)
cur = conn.cursor()
# ── helpers ───────────────────────────────────────────────────────────────
def uid():
return str(uuid.uuid4())
def ts(days_ago=0):
"""Timestamp in ms, optionally shifted into the past."""
return int((time.time() - days_ago * 86400) * 1000)
# ── fake data definitions ────────────────────────────────────────────────
CLIENTS = [
{"name": "Acme Corp", "industry": "Manufacturing"},
{"name": "Globex Inc", "industry": "Technology"},
{"name": "Initech Solutions", "industry": "Finance"},
{"name": "Umbrella Labs", "industry": "Healthcare"},
{"name": "Wayne Enterprises", "industry": "Defense & Engineering"},
]
PROJECTS_PER_CLIENT = [
# (name, status)
[("Website Redesign", "active"), ("ERP Migration", "active")],
[("AI Chatbot MVP", "active"), ("Cloud Infrastructure", "archived")],
[("Compliance Audit Tool", "active"),],
[("Patient Portal v2", "active"), ("Lab Inventory System", "active"), ("R&D Dashboard", "archived")],
[("Bat-Signal Network", "active"), ("Vehicle Fleet Tracker", "active")],
]
TASK_TEMPLATES = [
("Design homepage mockup", "Create wireframes and high-fidelity mockups for the landing page", "todo", "high"),
("Set up CI/CD pipeline", "Configure GitHub Actions with build, test, deploy stages", "in-progress", "high"),
("Write unit tests for auth", "Cover login, register, and token refresh flows", "todo", "medium"),
("Database schema review", "Review ERD and optimize indexes for production workload", "done", "medium"),
("Implement search feature", "Full-text search across projects and notes", "todo", "low"),
("Fix timezone bug", "Date picker shows wrong day for UTC+offset users", "in-progress", "high"),
("API rate limiting", "Add sliding-window rate limiter to public endpoints", "todo", "medium"),
("Onboarding walkthrough", "Build step-by-step tour for new users", "todo", "low"),
("Performance profiling", "Identify and fix top 3 slow queries", "done", "high"),
("Accessibility audit", "Ensure WCAG 2.1 AA compliance across all pages", "todo", "medium"),
("Mobile responsive layout", "Adapt dashboard for tablets and phones", "in-progress", "medium"),
("Export to PDF", "Allow users to export reports and invoices to PDF", "todo", "low"),
]
TIMELINE_TEMPLATES = [
("Project Kickoff", 0, None),
("Design Phase Complete", 14, None),
("Alpha Release", 30, None),
("Beta Testing", 45, 60),
("User Acceptance Testing", 60, 75),
("Production Launch", 90, None),
("Post-Launch Review", 100, None),
]
NOTE_TEMPLATES = [
("Meeting Notes — Kickoff",
"## Attendees\n- Product Owner\n- Dev Lead\n- Designer\n\n## Key Decisions\n1. Use React + TypeScript stack\n2. Two-week sprint cycles\n3. MVP scope: auth, dashboard, CRUD\n\n## Action Items\n- [ ] Set up repo\n- [ ] Create Figma workspace\n- [ ] Schedule daily standups"),
("Architecture Decision Record",
"## ADR-001: Database Choice\n\n**Status:** Accepted\n\n**Context:** Need a lightweight, embedded database for local-first architecture.\n\n**Decision:** SQLite with WAL mode via better-sqlite3.\n\n**Consequences:**\n- Fast reads, good enough writes\n- No external service dependency\n- Limited concurrent write throughput (acceptable for single-user app)"),
("Sprint Retrospective",
"## What went well\n- Shipped auth flow ahead of schedule\n- Good collaboration between design and dev\n\n## What could improve\n- Too many context switches mid-sprint\n- Need clearer acceptance criteria on tickets\n\n## Actions\n- Tech lead to review tickets before sprint start\n- Block Friday afternoons for deep work"),
("Research: API Integrations",
"## Potential Integrations\n\n### Stripe\n- Webhooks for subscription events\n- Customer portal for self-service\n\n### SendGrid\n- Transactional emails (welcome, reset password)\n- Monthly digest newsletter\n\n### Sentry\n- Error tracking in production\n- Performance monitoring\n\n**Next step:** Create proof-of-concept for Stripe integration"),
]
# ── insert data ───────────────────────────────────────────────────────────
client_ids = []
project_ids = []
print("\n── Creating clients ──")
for i, c in enumerate(CLIENTS):
cid = uid()
client_ids.append(cid)
cur.execute(
"INSERT INTO clients (id, parent_id, name, industry, created_at) VALUES (?, ?, ?, ?, ?)",
(cid, None, c["name"], c["industry"], ts(random.randint(60, 180)))
)
print(f"{c['name']}")
print("\n── Creating projects ──")
for i, proj_list in enumerate(PROJECTS_PER_CLIENT):
for pname, pstatus in proj_list:
pid = uid()
project_ids.append((pid, client_ids[i]))
cur.execute(
"INSERT INTO projects (id, client_id, name, status, ai_summary, created_at) VALUES (?, ?, ?, ?, ?, ?)",
(pid, client_ids[i], pname, pstatus, None, ts(random.randint(30, 90)))
)
print(f"{pname}{CLIENTS[i]['name']}")
print("\n── Creating tasks ──")
task_count = 0
for pid, _cid in project_ids:
# 3-5 random tasks per project
selected = random.sample(TASK_TEMPLATES, k=random.randint(3, 5))
for title, desc, status, priority in selected:
tid = uid()
due = ts(-random.randint(5, 45)) # future dates
cur.execute(
"INSERT INTO tasks (id, project_id, title, description, status, priority, assignee, due_date, is_ai_suggested, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(tid, pid, title, desc, status, priority, random.choice(["Alice", "Bob", "Carol", None]),
due, 0, ts(random.randint(1, 30)))
)
task_count += 1
print(f"{task_count} tasks created across {len(project_ids)} projects")
print("\n── Creating timeline events ──")
event_count = 0
for pid, _cid in project_ids:
base_days_ago = random.randint(10, 60)
for title, offset, end_offset in TIMELINE_TEMPLATES:
eid = uid()
event_date = ts(base_days_ago - offset) # spread into future
end_date = ts(base_days_ago - end_offset) if end_offset else None
is_completed = 1 if offset < 20 else 0
cur.execute(
"INSERT INTO timeline_events (id, project_id, title, date, end_date, is_completed, is_ai_suggested, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(eid, pid, title, event_date, end_date, is_completed, 0, ts(random.randint(10, 60)))
)
event_count += 1
print(f"{event_count} timeline events created")
print("\n── Creating notes ──")
note_count = 0
for pid, _cid in project_ids:
# 1-3 notes per project
selected = random.sample(NOTE_TEMPLATES, k=random.randint(1, 3))
for title, content in selected:
nid = uid()
created = ts(random.randint(1, 30))
cur.execute(
"INSERT INTO notes (id, project_id, title, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
(nid, pid, title, content, created, created)
)
note_count += 1
print(f"{note_count} notes created")
# ── commit & close ────────────────────────────────────────────────────────
conn.commit()
conn.close()
print(f"""
═══════════════════════════════════════
Seed complete!
{len(CLIENTS)} clients
{len(project_ids)} projects
{task_count} tasks
{event_count} timeline events
{note_count} notes
═══════════════════════════════════════
Restart adiuvAI to see the data.
""")

95
skills-lock.json Normal file
View File

@@ -0,0 +1,95 @@
{
"version": 1,
"skills": {
"adapt": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "a884f9cc4adb0b3da02d0f8becb1c36245adec7dcc087cd44e6054113755ac6e"
},
"animate": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "ce0f9cc82930d5c3e674918d363aa095870d70951d136f0f72e252f5954bbc85"
},
"audit": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "85ff89a25110dd68ebb30b45c67b33b8f2d2bb123d407d957329a2931f0a6878"
},
"bolder": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "46e3a6a52b8bb694ca01dae4d98be4d85ab35e2ba95eee93bcb472ff6c98a70c"
},
"clarify": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "3eec88b6f38165fda2a091cdb46f78311347aa0af8d9fa40112124fdaae3bd43"
},
"colorize": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "da21ea34a9ba5aac8c87b6df23ad5b273bf60b708e5493e6bf4727fa172d2346"
},
"critique": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "033e4a42923fc97741626421c0873fe25b90674076d3f6a45a9dc3a307f1918f"
},
"delight": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "f46bb3c71cfe635a7742b94516ba53f0c5bfac65430513e99f1162d6d4e2e71d"
},
"distill": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "eb53dd6f18bbeb4d1b2986eaa858c9014b3c50b8ed9fcb68d841450c0b48bd12"
},
"extract": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "3c7ecd324b70ce07d525a2f8ecc0cda566b16612f1b413f121e82a65ccee38a2"
},
"frontend-design": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "70c1738e2ead9b1118bbf77ce6d72f3b9a6fef91b6ba42579066350fe7d1e745"
},
"harden": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "54072e299abb30b20ddca38dcbb8c585ccd3dcecc414586d6279db1fccae3578"
},
"normalize": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "82deb8f724b0188afee2bcc4f00a33b7446212ff831feda6d0b515e6d9ff0cea"
},
"onboard": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "1e90eb71e79b019c50c6e4ab01d45da4c093090e26f25ee4b2250fafe5274e8a"
},
"optimize": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "36de9c64e36c778a01502ca9c98a7a6d54d4fa5215c62c01a2e93dcc5912d869"
},
"polish": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "12a83281065df7cecc24c17fdf9a126a13f664140ed6939c8230eb3f447d1aa3"
},
"quieter": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "bdf6069485ed66c6da4ad6932319d56c06034198d7e8467bc7cdae8d3169759e"
},
"teach-impeccable": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"computedHash": "759bfe9a53d48b87d60352db3403b62a0663e5187b2a2bd61d43657ac48d1a11"
}
}
}

View File

@@ -1,230 +0,0 @@
/**
* ChatCopilot — LangChain-compatible ChatModel adapter for the GitHub Copilot SDK.
*
* Wraps the CopilotClient's session API so it can be used as a drop-in
* BaseChatModel within LangGraph, making the orchestrator provider-agnostic.
*
* Accepts a client-getter function to avoid module duplication issues when
* this file is code-split into a separate chunk by Vite.
*/
import { SimpleChatModel, type BaseChatModelCallOptions } from '@langchain/core/language_models/chat_models';
import type { BaseMessage } from '@langchain/core/messages';
import { AIMessageChunk } from '@langchain/core/messages';
import { ChatGenerationChunk } from '@langchain/core/outputs';
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
import type { StructuredTool } from '@langchain/core/tools';
type CopilotClientType = import('@github/copilot-sdk').CopilotClient;
/** Minimal shape of a Copilot SDK Tool (avoids importing the full SDK type) */
type CopilotNativeTool = {
name: string;
description?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parameters?: any;
handler: (args: unknown) => Promise<unknown>;
};
const COPILOT_TIMEOUT = 120_000;
export class ChatCopilot extends SimpleChatModel<BaseChatModelCallOptions> {
private getClient: () => CopilotClientType | null;
/** Native Copilot SDK tools, populated by bindTools() */
private _copilotTools: CopilotNativeTool[] = [];
constructor(getClient: () => CopilotClientType | null, tools: CopilotNativeTool[] = []) {
super({});
this.getClient = getClient;
this._copilotTools = tools;
}
_llmType(): string {
return 'copilot';
}
private requireClient(): CopilotClientType {
const client = this.getClient();
if (!client) {
throw new Error('CopilotClient not initialized. Please check that Copilot CLI is authenticated (copilot auth login).');
}
return client;
}
/**
* Convert LangChain StructuredTools to Copilot SDK native tools and return a
* new ChatCopilot instance that will pass them to createSession().
* The SDK handles the full tool-calling loop internally — no LangChain ToolMessages needed.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
override bindTools(tools: StructuredTool[]): any {
const copilotTools: CopilotNativeTool[] = tools.map((t) => ({
name: t.name,
description: t.description ?? undefined,
parameters: t.schema,
handler: async (args: unknown) => {
console.log(`[ChatCopilot] tool handler called: ${t.name}`, JSON.stringify(args));
const result = await t.invoke(args as Record<string, unknown>);
const output = typeof result === 'string' ? result : JSON.stringify(result);
console.log(`[ChatCopilot] tool handler result: ${t.name}`, output.slice(0, 200));
return output;
},
}));
console.log(`[ChatCopilot] bindTools() called with:`, copilotTools.map((t) => t.name));
return new ChatCopilot(this.getClient, copilotTools);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async _call(messages: BaseMessage[], _options: this['ParsedCallOptions'], _runManager?: CallbackManagerForLLMRun): Promise<string> {
const client = this.requireClient();
// Extract system message and user prompt from LangChain messages
const systemContent = messages
.filter((m) => m._getType() === 'system')
.map((m) => (typeof m.content === 'string' ? m.content : ''))
.join('\n');
const userContent = messages
.filter((m) => m._getType() === 'human')
.map((m) => (typeof m.content === 'string' ? m.content : ''))
.join('\n');
const hasTools = this._copilotTools.length > 0;
const session = await client.createSession({
// When tools are registered, use append mode so the SDK can inject its tool-calling
// instructions before our content. mode:'replace' strips those SDK-managed sections,
// causing the model to never see/call registered tools.
systemMessage: systemContent
? hasTools
? { content: systemContent }
: { mode: 'replace', content: systemContent }
: undefined,
// Pass native tools when available — SDK handles the agentic tool-calling loop
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...(hasTools ? { tools: this._copilotTools as any[] } : { availableTools: [] }),
streaming: false,
});
try {
const result = await session.sendAndWait({ prompt: userContent }, COPILOT_TIMEOUT);
return result?.data.content ?? '';
} finally {
await session.destroy().catch(() => { /* ignore cleanup errors */ });
}
}
async *_streamResponseChunks(
messages: BaseMessage[],
_options: this['ParsedCallOptions'],
_runManager?: CallbackManagerForLLMRun,
): AsyncGenerator<ChatGenerationChunk> {
const client = this.requireClient();
const systemContent = messages
.filter((m) => m._getType() === 'system')
.map((m) => (typeof m.content === 'string' ? m.content : ''))
.join('\n');
const userContent = messages
.filter((m) => m._getType() === 'human')
.map((m) => (typeof m.content === 'string' ? m.content : ''))
.join('\n');
const hasTools = this._copilotTools.length > 0;
console.log(`[ChatCopilot] _streamResponseChunks: hasTools=${hasTools}, tools=[${this._copilotTools.map((t) => t.name).join(', ')}]`);
console.log(`[ChatCopilot] systemMessage mode: ${hasTools ? 'append' : 'replace'}`);
const session = await client.createSession({
// Same append-vs-replace logic as _call: tools require append mode so the SDK
// can inject its tool-calling instructions before our project context.
systemMessage: systemContent
? hasTools
? { content: systemContent }
: { mode: 'replace', content: systemContent }
: undefined,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...(hasTools ? { tools: this._copilotTools as any[] } : { availableTools: [] }),
streaming: true,
});
console.log(`[ChatCopilot] session created: ${session.sessionId}`);
// Buffer chunks via event listener and yield them
const chunks: string[] = [];
let done = false;
let sessionError: Error | null = null;
let resolveNext: (() => void) | null = null;
const unsubDelta = session.on('assistant.message_delta', (event) => {
const delta = event.data.deltaContent;
if (delta) {
chunks.push(delta);
resolveNext?.();
}
});
const unsubEnd = session.on('session.idle', () => {
console.log('[ChatCopilot] session.idle received');
done = true;
resolveNext?.();
});
const unsubError = session.on('session.error', (event) => {
console.error('[ChatCopilot] session.error received:', event.data.message);
sessionError = new Error(event.data.message);
done = true;
resolveNext?.();
});
// Log all events to understand SDK behaviour with tools
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const unsubAll = session.on((event: any) => {
if (!['assistant.message_delta'].includes(event.type)) {
console.log(`[ChatCopilot] SDK event: ${event.type}`, JSON.stringify(event.data ?? {}).slice(0, 300));
}
});
// Fire the request (don't await — we'll drain via events).
const sendPromise = session.sendAndWait({ prompt: userContent }, COPILOT_TIMEOUT);
// If sendAndWait rejects before any session events fire (e.g. send() throws
// internally due to a listModels/auth failure), wake up the while loop so it
// doesn't hang waiting for session.idle that will never arrive.
sendPromise.catch((err: unknown) => {
if (!done) {
sessionError = err instanceof Error ? err : new Error(String(err));
done = true;
resolveNext?.();
}
});
try {
while (!done || chunks.length > 0) {
if (chunks.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const text = chunks.shift()!;
const chunk = new ChatGenerationChunk({
message: new AIMessageChunk({ content: text }),
text,
});
await _runManager?.handleLLMNewToken(text);
yield chunk;
} else if (!done) {
await new Promise<void>((resolve) => {
resolveNext = resolve;
});
}
}
// Propagate any error surfaced via session.error event or sendAndWait rejection
if (sessionError) throw sessionError;
} finally {
unsubDelta();
unsubEnd();
unsubError();
unsubAll();
await session.destroy().catch(() => { /* ignore cleanup errors */ });
}
}
}

View File

@@ -1,61 +0,0 @@
import { app } from 'electron';
import { registerProvider, type AIProvider } from './provider';
// Dynamic import type — @github/copilot-sdk is ESM-only
type CopilotClientType = import('@github/copilot-sdk').CopilotClient;
let client: CopilotClientType | null = null;
let isReady = false;
const copilotProvider: AIProvider = {
name: 'copilot',
displayName: 'GitHub Copilot',
usesExternalAuth: true,
async initialize(): Promise<boolean> {
try {
// Stop existing client if re-initializing
if (client) {
// eslint-disable-next-line @typescript-eslint/no-empty-function
await client.stop().catch(() => {});
client = null;
}
const { CopilotClient } = await import('@github/copilot-sdk');
// No githubToken — uses stored OAuth credentials from Copilot CLI
// (authenticate first with `copilot auth login`)
client = new CopilotClient({
autoStart: true,
autoRestart: true,
logLevel: 'warning',
});
await client.start();
isReady = true;
console.log('[AI] CopilotClient started (using CLI OAuth credentials)');
return true;
} catch (err) {
console.error('[AI] Failed to start CopilotClient:', err);
client = null;
isReady = false;
return false;
}
},
isReady(): boolean {
return isReady && client !== null;
},
};
/** Get the CopilotClient instance (null if not initialized). */
export function getCopilotClient(): CopilotClientType | null {
return client;
}
// Clean shutdown on app quit
app.on('before-quit', () => {
if (client) {
client.stop().catch((err: unknown) => console.error('[AI] Error stopping CopilotClient:', err));
}
});
registerProvider(copilotProvider);

View File

@@ -1,73 +0,0 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { getToken } from './token';
interface CopilotConfig {
copilot_tokens?: Record<string, string>;
}
/**
* Read the GitHub Copilot OAuth token from the CLI config file.
* Stored at ~/.copilot/config.json under copilot_tokens["{host}:{login}"].
* Returns the first available token, or null if unavailable.
*/
function readCopilotToken(): string | null {
try {
const raw = fs.readFileSync(
path.join(os.homedir(), '.copilot', 'config.json'),
'utf-8',
);
const cfg = JSON.parse(raw) as CopilotConfig;
const vals = Object.values(cfg.copilot_tokens ?? {});
return vals[0] ?? null;
} catch {
return null;
}
}
/**
* Embed a single text string using the best available credentials.
*
* Priority:
* 1. GitHub Copilot CLI token → OpenAI-compatible embeddings endpoint at
* https://api.githubcopilot.com
* 2. Stored OpenAI token → standard OpenAI embeddings API
*
* Throws if no credentials are available or the API call fails.
* Callers must .catch() this and handle the error without rejecting
* the surrounding tRPC mutation.
*/
export async function embedText(text: string): Promise<number[]> {
const { OpenAIEmbeddings } = await import('@langchain/openai');
const copilotToken = readCopilotToken();
let embeddingsInstance;
if (copilotToken) {
embeddingsInstance = new OpenAIEmbeddings({
apiKey: copilotToken,
model: 'text-embedding-3-small',
configuration: { baseURL: 'https://api.githubcopilot.com' },
});
} else {
const openaiToken = await getToken('openai');
if (!openaiToken) {
throw new Error(
'[Embeddings] No credentials available. Authenticate with Copilot CLI or add an OpenAI token in Settings.',
);
}
embeddingsInstance = new OpenAIEmbeddings({
apiKey: openaiToken,
model: 'text-embedding-3-small',
});
}
// embedDocuments returns number[][] — cast explicitly to satisfy strict TS
const results = (await embeddingsInstance.embedDocuments([text])) as number[][];
const vector = results[0] as number[] | undefined;
if (!vector || vector.length === 0) {
throw new Error('[Embeddings] Empty vector returned from embedding API');
}
return vector;
}

View File

@@ -1,81 +0,0 @@
/**
* LLM connector factory — returns a LangChain BaseChatModel for the active provider.
*
* The agent orchestration (LangGraph) is provider-independent. This module is
* the only place that knows how to create provider-specific LLM instances.
*/
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { getActiveProviderName, getActiveProvider } from './provider';
import { getToken } from './token';
import { getCopilotClient } from './copilot';
// ---------------------------------------------------------------------------
// Provider-specific factory functions (lazy-loaded)
// ---------------------------------------------------------------------------
async function createOpenAIModel(token: string): Promise<BaseChatModel> {
const { ChatOpenAI } = await import('@langchain/openai');
return new ChatOpenAI({
apiKey: token,
model: 'gpt-4o-mini',
temperature: 0.3,
streaming: true,
});
}
async function createAnthropicModel(token: string): Promise<BaseChatModel> {
const { ChatAnthropic } = await import('@langchain/anthropic');
return new ChatAnthropic({
apiKey: token,
model: 'claude-sonnet-4-20250514',
temperature: 0.3,
streaming: true,
});
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function createCopilotModel(_token: string): Promise<BaseChatModel> {
// GitHub Copilot uses the Copilot SDK subprocess for auth and API access.
// We wrap it in a LangChain-compatible adapter.
// Pass getCopilotClient from this chunk (same as copilot.ts) to avoid
// module duplication when chat-copilot.ts is code-split by Vite.
const { ChatCopilot } = await import('./chat-copilot');
return new ChatCopilot(getCopilotClient);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
const MODEL_FACTORIES: Record<string, (token: string) => Promise<BaseChatModel>> = {
openai: createOpenAIModel,
anthropic: createAnthropicModel,
copilot: createCopilotModel,
};
/**
* Get a LangChain BaseChatModel for the currently active AI provider.
* Returns null if no provider is configured or no token is available.
*/
export async function getLLM(): Promise<BaseChatModel | null> {
const providerName = getActiveProviderName();
const factory = MODEL_FACTORIES[providerName];
if (!factory) {
console.log(`[AI] No LLM factory for provider "${providerName}"`);
return null;
}
const provider = getActiveProvider();
const token = provider?.usesExternalAuth ? '' : await getToken(providerName);
if (!provider?.usesExternalAuth && !token) {
console.log(`[AI] No token available for provider "${providerName}"`);
return null;
}
try {
return await factory(token ?? '');
} catch (err) {
console.error(`[AI] Failed to create LLM for "${providerName}":`, err);
return null;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,93 +0,0 @@
import { getStore } from '../store';
import { getToken, setToken as storeToken } from './token';
export interface AIProvider {
/** Internal key, e.g. 'copilot', 'openai', 'anthropic' */
name: string;
/** Human-readable label shown in Settings UI */
displayName: string;
/** Initialize with a token. Returns true if the provider is ready. */
initialize(token: string): Promise<boolean>;
/** Whether the provider is initialized and ready to handle requests. */
isReady(): boolean;
/** If true, this provider uses external auth (e.g. CLI OAuth) and doesn't need a stored token. */
usesExternalAuth?: boolean;
}
const providers = new Map<string, AIProvider>();
let activeProvider: AIProvider | null = null;
/** Register a provider implementation. Call at import time. */
export function registerProvider(provider: AIProvider): void {
providers.set(provider.name, provider);
}
/** Get the currently active provider (may be null if none configured). */
export function getActiveProvider(): AIProvider | null {
return activeProvider;
}
/** Get the active provider's name from electron-store. */
export function getActiveProviderName(): string {
return getStore().get('aiProvider');
}
/** Switch to a different registered provider. */
function setActiveProviderName(name: string): void {
const provider = providers.get(name);
if (!provider) throw new Error(`Unknown AI provider: ${name}`);
activeProvider = provider;
getStore().set('aiProvider', name);
}
/** Store token for the active provider and re-initialize it. */
export async function saveTokenAndInit(token: string): Promise<void> {
const name = getActiveProviderName();
await storeToken(name, token);
const provider = providers.get(name);
if (provider) {
await provider.initialize(token);
activeProvider = provider;
}
}
/** Check whether the active provider has credentials (stored token or external auth). */
export async function hasActiveToken(): Promise<boolean> {
const name = getActiveProviderName();
const provider = providers.get(name);
// Providers with external auth (e.g. Copilot CLI OAuth) don't need a stored token
if (provider?.usesExternalAuth) return true;
const token = await getToken(name);
return token !== null && token.length > 0;
}
/**
* Initialize the AI subsystem on app startup.
* Reads the active provider from settings, loads its token from keychain,
* and calls provider.initialize() if a token exists.
*/
export async function initAI(): Promise<void> {
const name = getActiveProviderName();
const provider = providers.get(name);
if (!provider) {
console.log(`[AI] No provider registered for "${name}"`);
return;
}
// Providers with external auth (e.g. Copilot CLI OAuth) initialize without a stored token
if (provider.usesExternalAuth) {
const ready = await provider.initialize('');
activeProvider = provider;
console.log(`[AI] Provider "${provider.displayName}" initialized (external auth): ready=${ready}`);
return;
}
const token = await getToken(name);
if (token) {
const ready = await provider.initialize(token);
activeProvider = provider;
console.log(`[AI] Provider "${provider.displayName}" initialized: ready=${ready}`);
} else {
console.log(`[AI] No token stored for provider "${provider.displayName}"`);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -11,10 +11,11 @@
* @see AI_REFACTOR_PLAN.md — Phase 1, Step 1.4
*/
import { eq, and, or, like, isNull, asc, desc, gte, lte, SQL } from 'drizzle-orm';
import * as fs from 'fs';
import * as path from 'path';
import { eq, and, or, like, isNull, asc, desc, gte, lte, inArray, sql, SQL } from 'drizzle-orm';
import { getDb } from '../db';
import { tasks, projects, clients, checkpoints, notes, taskComments } from '../db/schema';
import { searchNotes, upsertWithVector } from '../db/vectordb';
import { tasks, projects, clients, timelineEvents, notes, noteEdits, taskComments, projectFolderFiles } from '../db/schema';
import type { WsToolCall } from '../../shared/api-types';
// ---------------------------------------------------------------------------
@@ -25,14 +26,24 @@ const TABLE_REGISTRY = {
tasks,
projects,
clients,
checkpoints,
notes,
taskComments,
timelineEvents,
// Alias: the backend sends "timelines" as the table name
timelines: timelineEvents,
projectFolderFiles,
} as const;
type TableName = keyof typeof TABLE_REGISTRY;
type AnyTable = (typeof TABLE_REGISTRY)[TableName];
// ---------------------------------------------------------------------------
// Filesystem constants
// ---------------------------------------------------------------------------
/** Maximum file content size returned by read_file_content (500 KB). */
const MAX_READ_SIZE_BYTES = 500 * 1024;
// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------
@@ -48,8 +59,11 @@ export class ExecutorError extends Error {
// Filter builder
// ---------------------------------------------------------------------------
/** Reserved filter keys that are not direct column matchers. */
const RESERVED_KEYS = new Set(['search', 'orderBy', 'orderDir', 'includeArchived', 'dueDateFrom', 'dueDateTo']);
/** Keys that are handled explicitly and should not be treated as direct column matchers. */
const RESERVED_KEYS = new Set(['search', 'orderBy', 'orderDir', 'includeArchived', 'limit', 'offset']);
const RANGE_FROM_RE = /^(.+)From$/;
const RANGE_TO_RE = /^(.+)To$/;
function buildConditions(
table: AnyTable,
@@ -61,11 +75,45 @@ function buildConditions(
for (const [key, value] of Object.entries(filters)) {
if (RESERVED_KEYS.has(key)) continue;
// Generic *From / *To range filters — e.g. dueDateFrom, createdAtFrom, dateFrom, completedAtTo
const fromMatch = RANGE_FROM_RE.exec(key);
if (fromMatch) {
const colName = fromMatch[1]!;
const col = tbl[colName];
if (col && value != null) {
conditions.push(gte(col as Parameters<typeof gte>[0], Number(value)));
}
continue;
}
const toMatch = RANGE_TO_RE.exec(key);
if (toMatch) {
const colName = toMatch[1]!;
const col = tbl[colName];
if (col && value != null) {
conditions.push(lte(col as Parameters<typeof lte>[0], Number(value)));
}
continue;
}
const col = tbl[key];
if (!col) continue; // Unknown column — skip silently
if (value === null) {
conditions.push(isNull(col as Parameters<typeof isNull>[0]));
} else if (typeof value === 'string' && value.includes(',')) {
const parts = value.split(',').map((s) => s.trim()).filter(Boolean);
if (parts.length > 1) {
conditions.push(inArray(col as Parameters<typeof inArray>[0], parts));
} else if (parts.length === 1) {
conditions.push(eq(col as Parameters<typeof eq>[0], parts[0]));
}
} else if (Array.isArray(value)) {
const parts = value.map((v) => String(v)).filter(Boolean);
if (parts.length > 1) {
conditions.push(inArray(col as Parameters<typeof inArray>[0], parts));
} else if (parts.length === 1) {
conditions.push(eq(col as Parameters<typeof eq>[0], parts[0]));
}
} else {
conditions.push(eq(col as Parameters<typeof eq>[0], value as Parameters<typeof eq>[1]));
}
@@ -98,20 +146,6 @@ function buildConditions(
}
}
// Date range filters
if (filters['dueDateFrom'] != null) {
const dateCol = tbl['dueDate'];
if (dateCol) {
conditions.push(gte(dateCol as Parameters<typeof gte>[0], Number(filters['dueDateFrom'])));
}
}
if (filters['dueDateTo'] != null) {
const dateCol = tbl['dueDate'];
if (dateCol) {
conditions.push(lte(dateCol as Parameters<typeof lte>[0], Number(filters['dueDateTo'])));
}
}
return conditions;
}
@@ -158,10 +192,24 @@ export class DrizzleExecutor {
return this.handleUpdate(payload);
case 'delete':
return this.handleDelete(payload);
case 'vector_upsert':
return this.handleVectorUpsert(payload);
case 'vector_search':
return this.handleVectorSearch(payload);
case 'count':
return this.handleCount(payload);
case 'propose_note_edit':
return this.handleProposeNoteEdit(payload);
case 'list_directory':
return this.handleListDirectory(payload);
case 'read_file_content':
return this.handleReadFileContent(payload);
case 'get_file_metadata':
return this.handleGetFileMetadata(payload);
case 'read_project_folder_manifest':
return this.handleReadProjectFolderManifest(payload);
case 'read_project_folder_file':
return this.handleReadProjectFolderFile(payload);
case 'list_projects_with_folder_manifests':
return this.handleListProjectsWithFolderManifests();
case 'get_page_details':
return this.handleGetPageDetails(payload);
default:
throw new ExecutorError(`Unknown action: "${action as string}"`);
}
@@ -181,14 +229,30 @@ export class DrizzleExecutor {
const withWhere = conditions.length > 0
? query.where(and(...conditions.filter(Boolean as unknown as (x: SQL) => x is SQL))!)
: query;
const withOrder = orderBy ? withWhere.orderBy(orderBy) : withWhere;
const rows = orderBy
? withWhere.orderBy(orderBy).all()
: withWhere.all();
// Default limit of 50 prevents context flooding for AI tool calls
const limit = filters['limit'] != null ? Number(filters['limit']) : 50;
const offset = filters['offset'] != null ? Number(filters['offset']) : 0;
const rows = withOrder.limit(limit).offset(offset).all();
return { rows };
}
private handleCount(payload: WsToolCall): Record<string, unknown> {
const table = this.getTable(payload.table!);
const filters = (payload.filters ?? {}) as Record<string, unknown>;
const conditions = buildConditions(table, filters);
const query = getDb().select({ count: sql<number>`count(*)` }).from(table);
const withWhere = conditions.length > 0
? query.where(and(...conditions.filter(Boolean as unknown as (x: SQL) => x is SQL))!)
: query;
const result = withWhere.get();
return { count: Number((result as { count: number } | undefined)?.count ?? 0) };
}
private handleGet(payload: WsToolCall): Record<string, unknown> {
const table = this.getTable(payload.table!);
const data = (payload.data ?? {}) as Record<string, unknown>;
@@ -198,7 +262,6 @@ export class DrizzleExecutor {
const tbl = table as unknown as Record<string, unknown>;
const idCol = tbl['id'] as Parameters<typeof eq>[0];
const row = getDb().select().from(table).where(eq(idCol, id as string)).get() ?? null;
return { row };
}
@@ -207,12 +270,19 @@ export class DrizzleExecutor {
const data = (payload.data ?? {}) as Record<string, unknown>;
const now = Date.now();
// Auto-set completedAt for tables that have the column
const completedAtPatch =
('completedAt' in table && !('completedAt' in data) &&
(data['status'] === 'done' || data['isCompleted'] === 1))
? { completedAt: now }
: {};
const values = {
id: crypto.randomUUID(),
...data,
createdAt: now,
// Set updatedAt for tables that have it (notes)
...(('updatedAt' in table) ? { updatedAt: now } : {}),
...completedAtPatch,
};
const row = getDb().insert(table).values(values).returning().get() ?? null;
@@ -228,11 +298,22 @@ export class DrizzleExecutor {
const updates = (data['updates'] ?? {}) as Record<string, unknown>;
const now = Date.now();
// Auto-set updatedAt for tables that have it
const withTimestamp = ('updatedAt' in table)
const baseTimestamp = ('updatedAt' in table)
? { ...updates, updatedAt: now }
: updates;
// Auto-set completedAt when status/isCompleted changes, unless caller provided it explicitly
const completedAtPatch: Record<string, unknown> = {};
if ('completedAt' in table && !('completedAt' in updates)) {
if (updates['status'] === 'done' || updates['isCompleted'] === 1) {
completedAtPatch['completedAt'] = now;
} else if (updates['status'] !== undefined || updates['isCompleted'] !== undefined) {
completedAtPatch['completedAt'] = null;
}
}
const withTimestamp = { ...baseTimestamp, ...completedAtPatch };
const tbl = table as unknown as Record<string, unknown>;
const idCol = tbl['id'] as Parameters<typeof eq>[0];
const row = getDb()
@@ -258,43 +339,329 @@ export class DrizzleExecutor {
return { deleted: true };
}
private async handleVectorUpsert(payload: WsToolCall): Promise<Record<string, unknown>> {
private handleProposeNoteEdit(payload: WsToolCall): Record<string, unknown> {
const data = (payload.data ?? {}) as Record<string, unknown>;
const noteId = data['noteId'] as string;
const projectId = (data['projectId'] as string | null) ?? null;
const content = data['content'] as string;
const vector = payload.vector;
const noteId = data['noteId'] as string | undefined;
const type = data['type'] as string | undefined;
const proposedContent = data['proposedContent'] as string | undefined;
if (!noteId || !content || !vector) {
throw new ExecutorError('vector_upsert requires data.noteId, data.content, and vector');
}
if (!noteId) throw new ExecutorError('"data.noteId" is required for propose_note_edit');
if (!type) throw new ExecutorError('"data.type" is required for propose_note_edit');
if (!proposedContent) throw new ExecutorError('"data.proposedContent" is required for propose_note_edit');
await upsertWithVector(noteId, projectId, content, vector);
return { ok: true };
const values = {
id: crypto.randomUUID(),
noteId,
type,
proposedContent,
anchorBefore: (data['anchorBefore'] as string | null) ?? null,
anchorText: (data['anchorText'] as string | null) ?? null,
reasoning: (data['reasoning'] as string | null) ?? null,
agentId: (data['agentId'] as string | null) ?? null,
runId: (data['runId'] as string | null) ?? null,
status: 'pending' as const,
createdAt: Date.now(),
resolvedAt: null,
};
const row = getDb().insert(noteEdits).values(values).returning().get() ?? null;
return { id: values.id, row };
}
private async handleVectorSearch(payload: WsToolCall): Promise<Record<string, unknown>> {
const vec = payload.vector;
const limit = payload.limit ?? 5;
// -------------------------------------------------------------------------
// Filesystem handlers
// -------------------------------------------------------------------------
if (!vec) {
throw new ExecutorError('vector_search requires a vector');
private async handleListDirectory(payload: WsToolCall): Promise<Record<string, unknown>> {
const data = (payload.data ?? {}) as Record<string, unknown>;
const dirPath = data['path'] as string | undefined;
if (!dirPath) throw new ExecutorError('"data.path" is required for list_directory');
const resolved = await fs.promises.realpath(path.resolve(dirPath));
let dirents: fs.Dirent[];
try {
dirents = await fs.promises.readdir(resolved, { withFileTypes: true });
} catch (err) {
throw new ExecutorError(
`Cannot read directory ${dirPath}: ${err instanceof Error ? err.message : String(err)}`,
);
}
// searchNotes accepts a query string and embeds it — for pre-vectorised search
// we call LanceDB directly via the vectordb internals. For now, use the
// payload data.query string as a fallback. A full implementation will be
// wired up in Step 1.6 when embedText is migrated to the backend.
const data = (payload.data ?? {}) as Record<string, unknown>;
const query = (data['query'] as string | undefined) ?? '';
const rawResults = await searchNotes(query, limit);
const results = rawResults.map((r) => ({
id: r.id,
content: r.content,
score: 1 - r._distance, // Convert distance to similarity score
const entries = dirents.map((d) => ({
name: d.name,
type: d.isDirectory() ? 'directory' : 'file',
path: path.join(resolved, d.name),
}));
return { results };
return { entries };
}
private async handleReadFileContent(payload: WsToolCall): Promise<Record<string, unknown>> {
const data = (payload.data ?? {}) as Record<string, unknown>;
const filePath = data['path'] as string | undefined;
if (!filePath) throw new ExecutorError('"data.path" is required for read_file_content');
const resolved = await fs.promises.realpath(path.resolve(filePath));
let stat: fs.Stats;
try {
stat = await fs.promises.stat(resolved);
} catch (err) {
throw new ExecutorError(
`Cannot stat ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
);
}
if (!stat.isFile()) {
throw new ExecutorError(`Not a file: ${filePath}`);
}
let content: string;
if (stat.size > MAX_READ_SIZE_BYTES) {
// Read only the first MAX_READ_SIZE_BYTES to prevent context saturation
const buf = Buffer.alloc(MAX_READ_SIZE_BYTES);
const fd = await fs.promises.open(resolved, 'r');
try {
await fd.read(buf, 0, MAX_READ_SIZE_BYTES, 0);
} finally {
await fd.close();
}
content = buf.toString('utf8') + '\n[…truncated]';
} else {
content = await fs.promises.readFile(resolved, 'utf8');
}
return { content };
}
private async handleGetFileMetadata(payload: WsToolCall): Promise<Record<string, unknown>> {
const data = (payload.data ?? {}) as Record<string, unknown>;
const filePath = data['path'] as string | undefined;
if (!filePath) throw new ExecutorError('"data.path" is required for get_file_metadata');
const resolved = await fs.promises.realpath(path.resolve(filePath));
let stat: fs.Stats;
try {
stat = await fs.promises.stat(resolved);
} catch (err) {
throw new ExecutorError(
`Cannot stat ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
);
}
return {
name: path.basename(resolved),
extension: path.extname(resolved).toLowerCase(),
size: stat.size,
createdAt: stat.birthtime.toISOString(),
modifiedAt: stat.mtime.toISOString(),
};
}
// -------------------------------------------------------------------------
// Project folder handlers
// -------------------------------------------------------------------------
private handleReadProjectFolderManifest(payload: WsToolCall): Record<string, unknown> {
const { projectId } = (payload.data ?? {}) as { projectId: string };
const proj = getDb().select().from(projects).where(eq(projects.id, projectId)).get();
if (!proj?.folderPath) return { folderPath: null, lastScannedAt: null, files: [] };
const files = getDb()
.select({
relPath: projectFolderFiles.relativePath,
kind: projectFolderFiles.kind,
summary: projectFolderFiles.summary,
mtimeMs: projectFolderFiles.mtimeMs,
})
.from(projectFolderFiles)
.where(eq(projectFolderFiles.projectId, projectId))
.all();
// On-demand mtime check: if not currently scanning, fire-and-forget rescan when deltas exist.
// Returns the current (possibly stale) manifest immediately; the rescan updates rows
// for the next call.
if (proj.folderLastScanStatus !== 'scanning') {
void import('../files/scanner')
.then(async ({ scanFolder }) => {
const delta = await scanFolder(projectId, proj.folderPath!);
if (
delta.newFiles.length > 0 ||
delta.changedFiles.length > 0 ||
delta.deletedRelPaths.length > 0
) {
const { startIndexSession } = await import('../files/indexer');
// eslint-disable-next-line @typescript-eslint/no-empty-function
void startIndexSession(projectId, () => {});
}
})
// eslint-disable-next-line @typescript-eslint/no-empty-function
.catch(() => {});
}
return {
folderPath: proj.folderPath,
lastScannedAt: proj.folderLastScannedAt,
files,
};
}
private async handleReadProjectFolderFile(payload: WsToolCall): Promise<Record<string, unknown>> {
const { projectId, relativePath, offset, length } = (payload.data ?? {}) as {
projectId: string;
relativePath: string;
offset?: number;
length?: number;
};
if (!relativePath || relativePath.includes('..') || path.isAbsolute(relativePath)) {
throw new ExecutorError('Access denied');
}
const proj = getDb().select().from(projects).where(eq(projects.id, projectId)).get();
if (!proj?.folderPath) return { content: '', kind: 'missing', totalSize: 0 };
const abs = path.join(proj.folderPath, relativePath);
if (!path.resolve(abs).startsWith(path.resolve(proj.folderPath))) {
throw new ExecutorError('Access denied');
}
try {
const stat = await fs.promises.stat(abs);
const ext = path.extname(relativePath).toLowerCase();
if (['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
const buf = await fs.promises.readFile(abs);
return { content: buf.toString('base64'), kind: 'image', totalSize: stat.size };
}
// PDF + DOCX: return full base64; backend extracts text + slices.
if (ext === '.pdf' || ext === '.docx') {
const buf = await fs.promises.readFile(abs);
return {
content: buf.toString('base64'),
kind: ext === '.pdf' ? 'pdf' : 'docx',
totalSize: stat.size,
};
}
// Text: slice at offset/length on Electron side to keep WS payload small.
const start = Math.max(0, offset ?? 0);
const want = Math.max(1, Math.min(length ?? MAX_READ_SIZE_BYTES, MAX_READ_SIZE_BYTES));
const end = Math.min(start + want, stat.size);
const len = Math.max(0, end - start);
if (len === 0) {
return { content: '', kind: 'text', totalSize: stat.size };
}
const buf = Buffer.alloc(len);
const fd = await fs.promises.open(abs, 'r');
try {
await fd.read(buf, 0, len, start);
} finally {
await fd.close();
}
return { content: buf.toString('utf8'), kind: 'text', totalSize: stat.size };
} catch {
return { content: '', kind: 'error', totalSize: 0 };
}
}
// -------------------------------------------------------------------------
// Contextual agent: composite read op
// -------------------------------------------------------------------------
private handleGetPageDetails(payload: WsToolCall): Record<string, unknown> {
const db = getDb();
// entity_type is sent as the `table` field by the backend execute_on_client call.
const entityType = payload.table ?? '';
const entityId = (payload.data?.['entityId'] as string | null | undefined) ?? undefined;
switch (entityType) {
case 'project': {
if (!entityId) throw new ExecutorError('get_page_details: entityId required for project');
const project = db.select().from(projects).where(eq(projects.id, entityId)).get() ?? null;
if (!project) return { project: null, tasks: [], notes: [], milestones: [], events: [] };
const projectTasks = db.select().from(tasks).where(eq(tasks.projectId, entityId)).all();
const projectNotes = db
.select({
id: notes.id,
title: notes.title,
aiSummary: notes.aiSummary,
updatedAt: notes.updatedAt,
})
.from(notes)
.where(eq(notes.projectId, entityId))
.all();
const events = db.select().from(timelineEvents).where(eq(timelineEvents.projectId, entityId)).all();
return {
project,
tasks: projectTasks,
notes: projectNotes,
milestones: events.filter((e) => e.type === 'milestone'),
events,
};
}
case 'task': {
if (!entityId) throw new ExecutorError('get_page_details: entityId required for task');
const task = db.select().from(tasks).where(eq(tasks.id, entityId)).get() ?? null;
const project = task?.projectId
? (db.select().from(projects).where(eq(projects.id, task.projectId)).get() ?? null)
: null;
const comments = db.select().from(taskComments).where(eq(taskComments.taskId, entityId)).all();
return { task, project, comments };
}
case 'note': {
if (!entityId) throw new ExecutorError('get_page_details: entityId required for note');
const note = db.select().from(notes).where(eq(notes.id, entityId)).get() ?? null;
return { note };
}
case 'tasks_all':
return { tasks: db.select().from(tasks).all() };
case 'projects_all':
return { projects: db.select().from(projects).all() };
case 'timeline_all':
return { events: db.select().from(timelineEvents).all() };
default:
throw new ExecutorError(`get_page_details: unknown entityType "${entityType}"`);
}
}
private handleListProjectsWithFolderManifests(): Record<string, unknown> {
const projs = getDb()
.select()
.from(projects)
.where(sql`${projects.folderPath} IS NOT NULL`)
.all();
const out: Array<unknown> = [];
for (const p of projs) {
const files = getDb()
.select({
relPath: projectFolderFiles.relativePath,
kind: projectFolderFiles.kind,
summary: projectFolderFiles.summary,
mtimeMs: projectFolderFiles.mtimeMs,
})
.from(projectFolderFiles)
.where(eq(projectFolderFiles.projectId, p.id))
.all();
out.push({
projectId: p.id,
projectName: p.name,
folderPath: p.folderPath,
lastScannedAt: p.folderLastScannedAt,
files,
});
}
return { projects: out };
}
}

View File

@@ -0,0 +1,49 @@
import { app } from 'electron';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
const FILENAME_MAX = 200;
function sanitizeFilename(name: string): string {
const stripped = name
.replace(/[\\/]/g, '_')
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x1f]/g, '')
.replace(/^\.+/, '');
return stripped.length > FILENAME_MAX ? stripped.slice(0, FILENAME_MAX) : stripped;
}
export function attachmentsRoot(): string {
return path.join(app.getPath('userData'), 'attachments');
}
export function absolutePath(storedPath: string): string {
return path.join(attachmentsRoot(), storedPath);
}
export async function copyIntoTask(
taskId: string,
sourcePath: string,
filename: string,
): Promise<{ storedPath: string }> {
const safeName = sanitizeFilename(filename);
const dir = path.join(attachmentsRoot(), taskId);
await fs.mkdir(dir, { recursive: true });
const finalName = `${randomUUID()}-${safeName}`;
const dest = path.join(dir, finalName);
await fs.copyFile(sourcePath, dest);
return { storedPath: path.join(taskId, finalName) };
}
export async function deleteStored(storedPath: string): Promise<void> {
const abs = absolutePath(storedPath);
await fs.unlink(abs).catch((err) => {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
});
}
export async function deleteTaskDir(taskId: string): Promise<void> {
const dir = path.join(attachmentsRoot(), taskId);
await fs.rm(dir, { recursive: true, force: true });
}

View File

@@ -1,6 +1,6 @@
/**
* Auth manager — handles registration, login, token refresh, and profile
* retrieval against the Adiuva backend API.
* retrieval against the AdiuvAI backend API.
*
* Singleton. Tokens are persisted via the two-tier storage in `token.ts`
* (safeStorage + electron-store fallback).
@@ -22,7 +22,7 @@ import type { AuthTokens, UserProfile } from '../../shared/api-types';
const TOKEN_KEYS = {
access: 'auth_access',
refresh: 'auth_refresh',
/** Stored as string representation of Unix-epoch seconds. */
/** Stored as string representation of Unix-epoch milliseconds. */
expiresAt: 'auth_expires_at',
} as const;
@@ -32,6 +32,28 @@ const REFRESH_WINDOW_SEC = 5 * 60; // 5 minutes
/** Maximum request timeout (ms). */
const REQUEST_TIMEOUT_MS = 10_000;
// ---------------------------------------------------------------------------
// Memory types
// ---------------------------------------------------------------------------
export interface RelationOut {
id: string;
subjectLabel: string;
subjectType: string;
predicate: string;
objectLabel: string;
objectType: string;
confidence: number;
lastConfirmedAt: number | null;
}
export interface RelationPatch {
subjectLabel?: string;
objectLabel?: string;
predicate?: string;
confidence?: number;
}
// ---------------------------------------------------------------------------
// Error types
// ---------------------------------------------------------------------------
@@ -50,10 +72,27 @@ export class AuthError extends Error {
// AuthManager
// ---------------------------------------------------------------------------
/** Tracks a pending OAuth login promise until the deep-link callback arrives. */
interface PendingOAuth {
provider: string;
resolve: (tokens: AuthTokens) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
/** How long (ms) to wait for the user to complete the browser OAuth flow. */
const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
export class AuthManager {
private static instance: AuthManager | null = null;
private refreshPromise: Promise<void> | null = null;
/**
* Pending OAuth login promises keyed by state param.
* One entry per in-flight OAuth flow (practically always ≤ 1).
*/
private _pendingOAuth = new Map<string, PendingOAuth>();
// eslint-disable-next-line @typescript-eslint/no-empty-function
private constructor() {}
@@ -69,8 +108,11 @@ export class AuthManager {
// -------------------------------------------------------------------------
/** Register a new account and store the returned tokens. */
async register(email: string, password: string): Promise<AuthTokens> {
const data = await this.post<AuthTokens>('/api/v1/auth/register', { email, password });
async register(email: string, password: string, name?: string, surname?: string): Promise<AuthTokens> {
const body: Record<string, unknown> = { email, password };
if (name) body.name = name;
if (surname) body.surname = surname;
const data = await this.post<AuthTokens>('/api/v1/auth/register', body);
const tokens = AuthTokensSchema.parse(data);
await this.storeTokens(tokens);
return tokens;
@@ -104,9 +146,11 @@ export class AuthManager {
// Check expiry — refresh if within the window
const expiresAtStr = await getToken(TOKEN_KEYS.expiresAt);
if (expiresAtStr) {
const expiresAt = Number(expiresAtStr);
// Backend returns expires_at in milliseconds; convert to seconds.
const expiresAtSec = Math.floor(Number(expiresAtStr) / 1000);
const nowSec = Math.floor(Date.now() / 1000);
if (expiresAt - nowSec < REFRESH_WINDOW_SEC) {
if (expiresAtSec - nowSec < REFRESH_WINDOW_SEC) {
const isExpired = nowSec >= expiresAtSec;
// Coalesce concurrent refresh calls
if (!this.refreshPromise) {
this.refreshPromise = this.refreshTokens().finally(() => {
@@ -117,7 +161,14 @@ export class AuthManager {
await this.refreshPromise;
return (await getToken(TOKEN_KEYS.access)) ?? token;
} catch {
// Refresh failed — return existing token (may still work)
// Refresh failed — if the token is already expired, don't
// return a stale token that will certainly be rejected.
if (isExpired) {
console.warn('[Auth] Token expired and refresh failed — logging out.');
await this.logout();
return null;
}
// Token not yet expired — return it; it may still work.
return token;
}
}
@@ -138,6 +189,237 @@ export class AuthManager {
return UserProfileSchema.parse(data);
}
/** Update the user's profile (name, surname) on the backend. */
async updateProfile(fields: { name?: string; surname?: string }): Promise<UserProfile> {
const data = await this.put<UserProfile>('/api/v1/auth/me', fields);
return UserProfileSchema.parse(data);
}
/** Update core memory key/value pairs and optionally mark onboarding complete. */
async updateMemory(
memory: Record<string, string>,
markOnboarded = false,
): Promise<UserProfile> {
const data = await this.put<UserProfile>('/api/v1/auth/me/memory', {
memory,
markOnboarded,
});
return UserProfileSchema.parse(data);
}
/** One-shot LLM normalization for free-text onboarding answers. */
async normalizeOnboarding(
inputs: Record<string, string>,
): Promise<Record<string, string>> {
const res = await this.post<{ normalized: Record<string, string> }>('/api/v1/auth/onboarding/normalize', { inputs });
return res.normalized;
}
/** Reset onboarding so the wizard runs again. */
async resetOnboarding(): Promise<void> {
await this.post('/api/v1/auth/me/onboarding/reset', {});
}
/** Change password (email/password users only). */
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
await this.put('/api/v1/auth/me/password', {
currentPassword,
newPassword,
});
}
/** List linked OAuth providers for the current user. */
async listOAuthAccounts(): Promise<{ provider: string; providerEmail: string | null; createdAt: number }[]> {
return this.get('/api/v1/auth/me/oauth-accounts');
}
/** Unlink an OAuth provider from the current user. */
async unlinkOAuthAccount(provider: string): Promise<void> {
await this.httpDelete(`/api/v1/auth/me/oauth-accounts/${encodeURIComponent(provider)}`);
}
/** Update the user's avatar URL. */
async updateAvatar(avatarUrl: string): Promise<UserProfile> {
const data = await this.put<UserProfile>('/api/v1/auth/me/avatar', { avatarUrl });
return UserProfileSchema.parse(data);
}
/** Permanently delete the user's account. */
async deleteAccount(): Promise<void> {
await this.httpDelete('/api/v1/auth/me');
}
// ── Billing ────────────────────────────────────────────────────────
/** Get current subscription info. */
async getSubscription(): Promise<Record<string, unknown>> {
return this.get('/api/v1/billing/subscription');
}
/** Create a Stripe checkout session for a tier upgrade. */
async createCheckout(tier: string): Promise<{ checkoutUrl: string }> {
return this.post('/api/v1/billing/checkout', { tier });
}
/** Cancel the active subscription. */
async cancelSubscription(): Promise<void> {
await this.httpDelete('/api/v1/billing/subscription');
}
/** List billing invoices from Stripe. */
async listInvoices(): Promise<Record<string, unknown>[]> {
return this.get('/api/v1/billing/invoices');
}
/**
* Start a Google (or other provider) OAuth login flow.
*
* 1. Calls GET /api/v1/auth/oauth/{provider}/authorize to obtain the
* consent-screen URL and a PKCE state token from the backend.
* 2. Opens the URL in the system browser via shell.openExternal().
* 3. Returns a Promise that resolves with AuthTokens when the Electron app
* receives the deep-link callback (adiuvai://oauth/callback?...) and
* handleOAuthCallback() is called.
*
* Rejects after OAUTH_TIMEOUT_MS (5 min) if no callback arrives.
*/
async loginWithOAuth(provider: string): Promise<AuthTokens> {
// Fetch the authorization URL from the backend (public endpoint — no auth header needed).
const url = `${this.baseUrl}/api/v1/auth/oauth/${encodeURIComponent(provider)}/authorize`;
const res = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new AuthError(`Failed to get OAuth authorize URL: ${res.status}${text ? ` ${text}` : ''}`);
}
const json = (await res.json()) as { url: string; state: string };
// Open the consent screen in the system browser.
const { shell } = await import('electron');
await shell.openExternal(json.url);
// Wait for the deep-link callback to arrive.
return new Promise<AuthTokens>((resolve, reject) => {
const timer = setTimeout(() => {
this._pendingOAuth.delete(json.state);
reject(new AuthError('OAuth login timed out — no callback received within 5 minutes'));
}, OAUTH_TIMEOUT_MS);
this._pendingOAuth.set(json.state, { provider, resolve, reject, timer });
});
}
/**
* Called by the main process when the OS delivers an adiuvai:// deep link.
*
* Parses code + state from the URL, exchanges the code with the backend,
* stores the resulting JWT tokens, and resolves the pending loginWithOAuth()
* promise.
*
* If no pending flow matches the state (e.g. duplicate or stale callback),
* the call is silently ignored.
*/
async handleOAuthCallback(deepLinkUrl: string): Promise<void> {
let parsed: URL;
try {
parsed = new URL(deepLinkUrl);
} catch {
console.warn('[Auth] Received malformed deep-link URL:', deepLinkUrl);
return;
}
const code = parsed.searchParams.get('code');
const state = parsed.searchParams.get('state');
const provider = parsed.searchParams.get('provider');
if (!code || !state || !provider) {
console.warn('[Auth] Deep-link missing required params:', deepLinkUrl);
return;
}
const pending = this._pendingOAuth.get(state);
if (!pending) {
console.warn('[Auth] No pending OAuth flow for state:', state);
return;
}
clearTimeout(pending.timer);
this._pendingOAuth.delete(state);
try {
const data = await this.post<AuthTokens>(
`/api/v1/auth/oauth/${encodeURIComponent(provider)}/callback`,
{ code, state },
);
const tokens = AuthTokensSchema.parse(data);
await this.storeTokens(tokens);
pending.resolve(tokens);
} catch (err) {
pending.reject(err instanceof Error ? err : new AuthError('OAuth callback exchange failed'));
}
}
// ── Memory ────────────────────────────────────────────────────────────
/** Return all core memory k/v pairs (plaintext). */
async getCoreMemory(): Promise<Record<string, string>> {
return this.get<Record<string, string>>('/api/v1/memory/core');
}
/** Add or overwrite a core memory key/value pair. */
async addCoreKey(key: string, value: string): Promise<Record<string, string>> {
return this.post<Record<string, string>>('/api/v1/memory/core', { key, value });
}
/** Delete a core memory key (GDPR). */
async deleteCoreKey(key: string): Promise<void> {
await this.httpDelete(`/api/v1/memory/core/${encodeURIComponent(key)}`);
}
/** Return relational memory rows. */
async getRelationalMemory(): Promise<RelationOut[]> {
return this.get<RelationOut[]>('/api/v1/memory/relational');
}
/** Edit a relation row's labels, predicate, or confidence. */
async patchRelation(id: string, patch: RelationPatch): Promise<RelationOut> {
const url = `${this.baseUrl}/api/v1/memory/relational/${encodeURIComponent(id)}`;
const accessToken = await this.getAccessToken();
if (!accessToken) throw new AuthError('Not authenticated');
const res = await fetch(url, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` },
body: JSON.stringify(patch),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new AuthError(`${res.status} ${res.statusText}${text ? `: ${text}` : ''}`, res.status);
}
const json: unknown = await res.json();
return toCamelCase<RelationOut>(json);
}
/** Hard-delete a relation row (GDPR). */
async deleteRelation(id: string): Promise<void> {
await this.httpDelete(`/api/v1/memory/relational/${encodeURIComponent(id)}`);
}
/** Wipe all memory tiers for the current user (GDPR Art. 17). */
async forgetAll(): Promise<void> {
const url = `${this.baseUrl}/api/v1/memory/forget-all`;
const accessToken = await this.getAccessToken();
if (!accessToken) throw new AuthError('Not authenticated');
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}`, 'X-Confirm': 'true' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new AuthError(`${res.status} ${res.statusText}${text ? `: ${text}` : ''}`, res.status);
}
}
/** Explicitly refresh the token pair. */
async refreshTokens(): Promise<void> {
const refreshToken = await getToken(TOKEN_KEYS.refresh);
@@ -145,10 +427,27 @@ export class AuthManager {
throw new AuthError('No refresh token available');
}
const data = await this.post<AuthTokens>('/api/v1/auth/refresh', {
refreshToken,
// Use a direct fetch instead of this.post() to avoid sending the
// (possibly expired) access token in the Authorization header.
// The refresh endpoint only needs the refresh token in the body.
const url = `${this.baseUrl}/api/v1/auth/refresh`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(toSnakeCase({ refreshToken })),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
const tokens = AuthTokensSchema.parse(data);
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new AuthError(
`${res.status} ${res.statusText}${text ? `: ${text}` : ''}`,
res.status,
);
}
const json: unknown = await res.json();
const tokens = AuthTokensSchema.parse(toCamelCase<AuthTokens>(json));
await this.storeTokens(tokens);
}
@@ -202,6 +501,39 @@ export class AuthManager {
return toCamelCase<T>(json);
}
/**
* Generic PUT request to the backend (authenticated).
*/
private async put<T>(path: string, body: Record<string, unknown>): Promise<T> {
const url = `${this.baseUrl}${path}`;
const accessToken = await this.getAccessToken();
if (!accessToken) {
throw new AuthError('Not authenticated');
}
const res = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(toSnakeCase(body)),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new AuthError(
`${res.status} ${res.statusText}${text ? `: ${text}` : ''}`,
res.status,
);
}
const json: unknown = await res.json();
return toCamelCase<T>(json);
}
/**
* Generic GET request to the backend (authenticated).
*/
@@ -232,6 +564,35 @@ export class AuthManager {
const json: unknown = await res.json();
return toCamelCase<T>(json);
}
private async httpDelete<T = void>(path: string): Promise<T> {
const url = `${this.baseUrl}${path}`;
const accessToken = await this.getAccessToken();
if (!accessToken) {
throw new AuthError('Not authenticated');
}
const res = await fetch(url, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new AuthError(
`${res.status} ${res.statusText}${text ? `: ${text}` : ''}`,
res.status,
);
}
const text = await res.text();
if (!text) return undefined as T;
return toCamelCase<T>(JSON.parse(text));
}
}
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,45 @@
/**
* Device-specific backup encryption key.
*
* Generated randomly (256-bit) on first call and persisted via the same
* safeStorage + electron-store mechanism used for auth tokens (see token.ts).
* This key is device-bound — it never leaves the machine and is not derived
* from the user's password, so social-login users can use backups without issue.
*
* Usage:
* const key = await getBackupKey(); // Buffer of 32 bytes
*/
import { randomBytes } from 'crypto';
import { getToken, setToken } from '../ai/token';
const BACKUP_KEY_STORE_NAME = 'backup_key';
/**
* Return the device-specific backup encryption key (32 bytes).
*
* Generates a fresh key on first call and stores it via safeStorage so it
* survives app restarts. Subsequent calls return the same key.
*/
export async function getBackupKey(): Promise<Buffer> {
const stored = await getToken(BACKUP_KEY_STORE_NAME);
if (stored) {
return Buffer.from(stored, 'base64');
}
// First launch: generate a random 256-bit key and persist it.
const key = randomBytes(32);
await setToken(BACKUP_KEY_STORE_NAME, key.toString('base64'));
return key;
}
/**
* Delete the stored backup key (e.g. on device-wipe / factory reset).
* After this call, the next `getBackupKey()` will generate a new key —
* any backups encrypted with the old key will be unrecoverable.
*/
export async function deleteBackupKey(): Promise<void> {
const { deleteToken } = await import('../ai/token');
await deleteToken(BACKUP_KEY_STORE_NAME);
}

View File

@@ -0,0 +1,32 @@
import { app } from 'electron';
import type { FormatPrefs } from '../store';
export function detectFormatPrefs(): FormatPrefs {
const locale = app.getLocale();
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const hour12 = Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hour12;
const timeFormat = hour12 ? '12h' : '24h';
const dateFormat = inferDateFormat(locale);
return { timezone, timeFormat, dateFormat };
}
export function detectLanguage(): string {
const locale = app.getLocale(); // e.g. 'it-IT', 'en-US'
try {
const display = new Intl.DisplayNames([locale], { type: 'language' });
return display.of(locale) ?? locale;
} catch {
return locale;
}
}
function inferDateFormat(locale: string): string {
// MDY locales
const mdyLocales = ['en-US', 'en-PH', 'en-BZ'];
if (mdyLocales.some((l) => locale.startsWith(l))) return 'MM/dd/yyyy';
// YMD locales (CJK, ISO-oriented)
const ymdPrefixes = ['ja', 'zh', 'ko', 'hu', 'lt', 'sv', 'fi'];
if (ymdPrefixes.some((p) => locale.startsWith(p))) return 'yyyy-MM-dd';
// Default: DMY (most of the world)
return 'dd/MM/yyyy';
}

View File

@@ -1,91 +1,109 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { app } from 'electron';
import fs from 'node:fs';
import path from 'node:path';
import * as schema from './schema';
// SQL to create all tables if they don't exist (non-destructive push strategy)
const MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS clients (
id TEXT PRIMARY KEY,
parent_id TEXT,
name TEXT NOT NULL,
industry TEXT,
created_at INTEGER NOT NULL
);
/** Resolved path to the SQLite database file. Set once in initDb(). */
let _dbPath: string | null = null;
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
client_id TEXT,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
ai_summary TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
project_id TEXT,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'todo',
priority TEXT NOT NULL DEFAULT 'medium',
assignee TEXT,
due_date INTEGER,
is_ai_suggested INTEGER NOT NULL DEFAULT 0,
is_approved INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS checkpoints (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
title TEXT NOT NULL,
date INTEGER NOT NULL,
is_ai_suggested INTEGER NOT NULL DEFAULT 0,
is_approved INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
project_id TEXT,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS task_comments (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
author TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL
);
`;
/** Raw better-sqlite3 instance (needed for .backup() API). */
let _rawSqlite: Database.Database | null = null;
type DbInstance = ReturnType<typeof drizzle<typeof schema>>;
let dbInstance: DbInstance | null = null;
/**
* Resolve the migrations folder location.
*
* - Packaged: shipped via electron-forge `extraResource` → `<resourcesPath>/migrations`.
* - Dev: lives in the source tree at `<appPath>/src/main/db/migrations`. We do NOT
* resolve from `__dirname` because Vite bundles `src/main/**` into a single
* `.vite/build/main.js` and the migrations folder is not copied next to it.
*/
function resolveMigrationsFolder(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, 'migrations');
}
return path.join(app.getAppPath(), 'src', 'main', 'db', 'migrations');
}
/**
* One-time bootstrap for DBs created by the legacy hand-rolled MIGRATION_SQL.
*
* Pre-Drizzle-migrator era, schema was managed by ad-hoc CREATE TABLE IF NOT EXISTS
* + try/catch ALTER TABLE. Those DBs have all the tables from migrations 0000-0003
* but no `__drizzle_migrations` ledger. If we just call migrate(), it will try to
* re-run 0000 and crash on duplicate table.
*
* Strategy: if the DB looks pre-existing (has a `tasks` table) but no migrations
* ledger, create the ledger and mark all migrations EXCEPT the latest as applied.
* The migrator will then only run the latest one (0004 — adds `estimate` column +
* `task_attachments` table — both genuinely missing from legacy DBs).
*/
function bootstrapMigrationsLedger(sqlite: Database.Database, migrationsFolder: string): void {
const hasLedger = sqlite
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'")
.get();
if (hasLedger) return;
const hasTasks = sqlite
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='tasks'")
.get();
if (!hasTasks) return; // fresh DB — let the migrator create everything from scratch
// Legacy DB detected. Build the ledger Drizzle expects.
// Schema must match drizzle-orm/sqlite-core/dialect.js migrate():
// id SERIAL PRIMARY KEY, hash text NOT NULL, created_at numeric
sqlite.exec(`
CREATE TABLE __drizzle_migrations (
id SERIAL PRIMARY KEY,
hash text NOT NULL,
created_at numeric
);
`);
const journalPath = path.join(migrationsFolder, 'meta', '_journal.json');
const journal = JSON.parse(fs.readFileSync(journalPath, 'utf8')) as {
entries: { idx: number; tag: string; when: number }[];
};
// Mark everything except the latest entry as applied.
// Drizzle's migrator filters by `lastDbMigration.created_at < migration.folderMillis`,
// so seeding the second-to-last entry's `when` is sufficient.
const toMark = journal.entries.slice(0, -1);
if (toMark.length === 0) return;
const insert = sqlite.prepare(
'INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)',
);
for (const entry of toMark) {
// Hash value is opaque to the migrator — only created_at matters for the cutoff.
// Use the tag for traceability.
insert.run(entry.tag, entry.when);
}
}
export function initDb(): DbInstance {
const userDataPath = app.getPath('userData');
const dbPath = path.join(userDataPath, 'adiuva.db');
_dbPath = path.join(userDataPath, 'adiuvai.db');
const sqlite = new Database(dbPath);
const sqlite = new Database(_dbPath);
_rawSqlite = sqlite;
// Enable WAL mode for better concurrent read performance
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('synchronous = NORMAL');
// Run non-destructive migrations on every start
sqlite.exec(MIGRATION_SQL);
// Additive column migrations (SQLite has no ADD COLUMN IF NOT EXISTS)
try { sqlite.exec('ALTER TABLE tasks ADD COLUMN is_ai_suggested INTEGER NOT NULL DEFAULT 0'); } catch { /* already exists */ }
try { sqlite.exec('ALTER TABLE tasks ADD COLUMN is_approved INTEGER NOT NULL DEFAULT 1'); } catch { /* already exists */ }
const migrationsFolder = resolveMigrationsFolder();
bootstrapMigrationsLedger(sqlite, migrationsFolder);
dbInstance = drizzle(sqlite, { schema });
migrate(dbInstance, { migrationsFolder });
return dbInstance;
}
@@ -95,3 +113,31 @@ export function getDb(): DbInstance {
}
return dbInstance;
}
/** Returns the absolute path to the active SQLite database file. */
export function getDbPath(): string {
if (!_dbPath) throw new Error('Database not initialized.');
return _dbPath;
}
/**
* Returns the raw better-sqlite3 Database instance.
* Used by BackupManager for the `.backup()` API.
*/
export function getRawSqlite(): Database.Database {
if (!_rawSqlite) throw new Error('Database not initialized.');
return _rawSqlite;
}
/**
* Closes the database connection and clears all module-level references.
* Called by BackupManager before atomically replacing the DB file.
* After calling this, you must call `initDb()` again to re-open.
*/
export function closeDb(): void {
if (_rawSqlite) {
try { _rawSqlite.close(); } catch { /* ignore */ }
_rawSqlite = null;
}
dbInstance = null;
}

View File

@@ -0,0 +1,86 @@
CREATE TABLE `agent_run_actions` (
`id` text PRIMARY KEY NOT NULL,
`run_id` text NOT NULL,
`agent_id` text NOT NULL,
`verb` text NOT NULL,
`entity_type` text NOT NULL,
`entity_id` text,
`entity_title` text,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `agent_runs` (
`id` text PRIMARY KEY NOT NULL,
`agent_id` text NOT NULL,
`status` text DEFAULT 'running' NOT NULL,
`started_at` integer NOT NULL,
`completed_at` integer
);
--> statement-breakpoint
CREATE TABLE `clients` (
`id` text PRIMARY KEY NOT NULL,
`parent_id` text,
`name` text NOT NULL,
`industry` text,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `notes` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text,
`title` text NOT NULL,
`content` text DEFAULT '' NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `projects` (
`id` text PRIMARY KEY NOT NULL,
`client_id` text,
`name` text NOT NULL,
`status` text DEFAULT 'active' NOT NULL,
`ai_summary` text,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `task_comments` (
`id` text PRIMARY KEY NOT NULL,
`task_id` text NOT NULL,
`author` text NOT NULL,
`content` text NOT NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `tasks` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text,
`title` text NOT NULL,
`description` text,
`status` text DEFAULT 'todo' NOT NULL,
`priority` text DEFAULT 'medium' NOT NULL,
`assignee` text,
`due_date` integer,
`is_ai_suggested` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL,
`completed_at` integer
);
--> statement-breakpoint
CREATE TABLE `timeline_event_dependencies` (
`id` text PRIMARY KEY NOT NULL,
`from_event_id` text NOT NULL,
`to_event_id` text NOT NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `timeline_events` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text,
`title` text NOT NULL,
`date` integer NOT NULL,
`end_date` integer,
`type` text DEFAULT 'milestone' NOT NULL,
`is_completed` integer DEFAULT 0 NOT NULL,
`is_ai_suggested` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL,
`completed_at` integer
);

View File

@@ -0,0 +1,17 @@
CREATE TABLE `note_edits` (
`id` text PRIMARY KEY NOT NULL,
`note_id` text NOT NULL,
`type` text NOT NULL,
`anchor_before` text,
`anchor_text` text,
`proposed_content` text NOT NULL,
`status` text DEFAULT 'pending' NOT NULL,
`agent_id` text,
`run_id` text,
`reasoning` text,
`created_at` integer NOT NULL,
`resolved_at` integer
);
--> statement-breakpoint
ALTER TABLE `notes` ADD `ai_summary` text;--> statement-breakpoint
ALTER TABLE `notes` ADD `ai_summary_updated_at` integer;

View File

@@ -0,0 +1,10 @@
CREATE TABLE `task_briefings` (
`task_id` text PRIMARY KEY NOT NULL,
`briefing_markdown` text NOT NULL,
`canvas_draft` text,
`canvas_kind` text,
`citations` text,
`source_task_hash` text NOT NULL,
`generated_at` integer NOT NULL,
`model_version` text
);

View File

@@ -0,0 +1,8 @@
CREATE TABLE `task_brief_chats` (
`id` text PRIMARY KEY NOT NULL,
`task_id` text NOT NULL,
`role` text NOT NULL,
`content` text NOT NULL,
`is_error` integer DEFAULT false NOT NULL,
`created_at` integer NOT NULL
);

View File

@@ -0,0 +1,11 @@
CREATE TABLE `task_attachments` (
`id` text PRIMARY KEY NOT NULL,
`task_id` text NOT NULL,
`filename` text NOT NULL,
`mime_type` text,
`size_bytes` integer NOT NULL,
`stored_path` text NOT NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
ALTER TABLE `tasks` ADD `estimate` integer;

View File

@@ -0,0 +1,16 @@
CREATE TABLE `project_folder_files` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`relative_path` text NOT NULL,
`ext` text NOT NULL,
`kind` text NOT NULL,
`size_bytes` integer NOT NULL,
`mtime_ms` integer NOT NULL,
`summary` text,
`summary_updated_at` integer
);
--> statement-breakpoint
ALTER TABLE `projects` ADD `folder_path` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `folder_last_scanned_at` integer;--> statement-breakpoint
ALTER TABLE `projects` ADD `folder_last_scan_status` text DEFAULT 'idle';--> statement-breakpoint
ALTER TABLE `projects` ADD `folder_total_files` integer DEFAULT 0 NOT NULL;

View File

@@ -0,0 +1,23 @@
CREATE TABLE `ai_chat_messages` (
`id` text PRIMARY KEY NOT NULL,
`session_id` text NOT NULL,
`role` text NOT NULL,
`content` text NOT NULL,
`tool_calls` text,
`tool_results` text,
`scope` text,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `ai_chat_sessions` (
`id` text PRIMARY KEY NOT NULL,
`channel` text NOT NULL,
`title` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`last_scope` text
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `ai_chat_messages_session_created_idx` ON `ai_chat_messages` (`session_id`, `created_at`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `ai_chat_sessions_channel_updated_idx` ON `ai_chat_sessions` (`channel`, `updated_at`);

View File

@@ -0,0 +1,44 @@
-- Rename agent_runs → scout_runs and agent_run_actions → scout_run_actions
-- SQLite supports ALTER TABLE RENAME TO; column rename (agent_id → scout_id) requires recreate.
-- Step 1: rename agent_runs table
ALTER TABLE `agent_runs` RENAME TO `scout_runs`;
--> statement-breakpoint
-- Step 2: rename agent_run_actions table
ALTER TABLE `agent_run_actions` RENAME TO `scout_run_actions`;
--> statement-breakpoint
-- Step 3: rename agent_id column in scout_runs (SQLite requires full table recreate for column rename)
CREATE TABLE `__new_scout_runs` (
`id` text PRIMARY KEY NOT NULL,
`scout_id` text NOT NULL,
`status` text DEFAULT 'running' NOT NULL,
`started_at` integer NOT NULL,
`completed_at` integer
);
--> statement-breakpoint
INSERT INTO `__new_scout_runs` SELECT `id`, `agent_id`, `status`, `started_at`, `completed_at` FROM `scout_runs`;
--> statement-breakpoint
DROP TABLE `scout_runs`;
--> statement-breakpoint
ALTER TABLE `__new_scout_runs` RENAME TO `scout_runs`;
--> statement-breakpoint
-- Step 4: rename agent_id column in scout_run_actions
CREATE TABLE `__new_scout_run_actions` (
`id` text PRIMARY KEY NOT NULL,
`run_id` text NOT NULL,
`scout_id` text NOT NULL,
`verb` text NOT NULL,
`entity_type` text NOT NULL,
`entity_id` text,
`entity_title` text,
`created_at` integer NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_scout_run_actions` SELECT `id`, `run_id`, `agent_id`, `verb`, `entity_type`, `entity_id`, `entity_title`, `created_at` FROM `scout_run_actions`;
--> statement-breakpoint
DROP TABLE `scout_run_actions`;
--> statement-breakpoint
ALTER TABLE `__new_scout_run_actions` RENAME TO `scout_run_actions`;

View File

@@ -0,0 +1,16 @@
-- Create scout_suggestions table
CREATE TABLE `scout_suggestions` (
`id` text PRIMARY KEY NOT NULL,
`scout_id` text NOT NULL,
`source_type` text NOT NULL,
`source_msg_ref` text NOT NULL,
`category` text NOT NULL,
`payload` text,
`raw_subject` text,
`raw_snippet` text,
`status` text NOT NULL,
`proposed_at` integer NOT NULL,
`resolved_at` integer,
`resolved_entity_type` text,
`resolved_entity_id` text
);

View File

@@ -0,0 +1,537 @@
{
"version": "6",
"dialect": "sqlite",
"id": "163d917f-37b9-44a6-8edc-222ebf3f7f74",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"agent_run_actions": {
"name": "agent_run_actions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"run_id": {
"name": "run_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"verb": {
"name": "verb",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"entity_type": {
"name": "entity_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"entity_id": {
"name": "entity_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entity_title": {
"name": "entity_title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"agent_runs": {
"name": "agent_runs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'running'"
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"clients": {
"name": "clients",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"industry": {
"name": "industry",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"notes": {
"name": "notes",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"projects": {
"name": "projects",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"ai_summary": {
"name": "ai_summary",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_comments": {
"name": "task_comments",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tasks": {
"name": "tasks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'todo'"
},
"priority": {
"name": "priority",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'medium'"
},
"assignee": {
"name": "assignee",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"due_date": {
"name": "due_date",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_ai_suggested": {
"name": "is_ai_suggested",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"timeline_event_dependencies": {
"name": "timeline_event_dependencies",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"from_event_id": {
"name": "from_event_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"to_event_id": {
"name": "to_event_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"timeline_events": {
"name": "timeline_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"end_date": {
"name": "end_date",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'milestone'"
},
"is_completed": {
"name": "is_completed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"is_ai_suggested": {
"name": "is_ai_suggested",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,646 @@
{
"version": "6",
"dialect": "sqlite",
"id": "a52096e8-17fe-493a-a24a-4305c2953b3d",
"prevId": "163d917f-37b9-44a6-8edc-222ebf3f7f74",
"tables": {
"agent_run_actions": {
"name": "agent_run_actions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"run_id": {
"name": "run_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"verb": {
"name": "verb",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"entity_type": {
"name": "entity_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"entity_id": {
"name": "entity_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entity_title": {
"name": "entity_title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"agent_runs": {
"name": "agent_runs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'running'"
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"clients": {
"name": "clients",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"industry": {
"name": "industry",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"note_edits": {
"name": "note_edits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"anchor_before": {
"name": "anchor_before",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"anchor_text": {
"name": "anchor_text",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"proposed_content": {
"name": "proposed_content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"run_id": {
"name": "run_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reasoning": {
"name": "reasoning",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"resolved_at": {
"name": "resolved_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"notes": {
"name": "notes",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"ai_summary": {
"name": "ai_summary",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ai_summary_updated_at": {
"name": "ai_summary_updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"projects": {
"name": "projects",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"ai_summary": {
"name": "ai_summary",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_comments": {
"name": "task_comments",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tasks": {
"name": "tasks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'todo'"
},
"priority": {
"name": "priority",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'medium'"
},
"assignee": {
"name": "assignee",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"due_date": {
"name": "due_date",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_ai_suggested": {
"name": "is_ai_suggested",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"timeline_event_dependencies": {
"name": "timeline_event_dependencies",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"from_event_id": {
"name": "from_event_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"to_event_id": {
"name": "to_event_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"timeline_events": {
"name": "timeline_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"end_date": {
"name": "end_date",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'milestone'"
},
"is_completed": {
"name": "is_completed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"is_ai_suggested": {
"name": "is_ai_suggested",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,712 @@
{
"version": "6",
"dialect": "sqlite",
"id": "c2e44835-b24a-4410-babf-887a82a4568e",
"prevId": "a52096e8-17fe-493a-a24a-4305c2953b3d",
"tables": {
"agent_run_actions": {
"name": "agent_run_actions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"run_id": {
"name": "run_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"verb": {
"name": "verb",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"entity_type": {
"name": "entity_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"entity_id": {
"name": "entity_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entity_title": {
"name": "entity_title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"agent_runs": {
"name": "agent_runs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'running'"
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"clients": {
"name": "clients",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"industry": {
"name": "industry",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"note_edits": {
"name": "note_edits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"anchor_before": {
"name": "anchor_before",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"anchor_text": {
"name": "anchor_text",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"proposed_content": {
"name": "proposed_content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"run_id": {
"name": "run_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reasoning": {
"name": "reasoning",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"resolved_at": {
"name": "resolved_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"notes": {
"name": "notes",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"ai_summary": {
"name": "ai_summary",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ai_summary_updated_at": {
"name": "ai_summary_updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"projects": {
"name": "projects",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"ai_summary": {
"name": "ai_summary",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_briefings": {
"name": "task_briefings",
"columns": {
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"briefing_markdown": {
"name": "briefing_markdown",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"canvas_draft": {
"name": "canvas_draft",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"canvas_kind": {
"name": "canvas_kind",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"citations": {
"name": "citations",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source_task_hash": {
"name": "source_task_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generated_at": {
"name": "generated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"model_version": {
"name": "model_version",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_comments": {
"name": "task_comments",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tasks": {
"name": "tasks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'todo'"
},
"priority": {
"name": "priority",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'medium'"
},
"assignee": {
"name": "assignee",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"due_date": {
"name": "due_date",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_ai_suggested": {
"name": "is_ai_suggested",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"timeline_event_dependencies": {
"name": "timeline_event_dependencies",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"from_event_id": {
"name": "from_event_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"to_event_id": {
"name": "to_event_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"timeline_events": {
"name": "timeline_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"end_date": {
"name": "end_date",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'milestone'"
},
"is_completed": {
"name": "is_completed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"is_ai_suggested": {
"name": "is_ai_suggested",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,765 @@
{
"version": "6",
"dialect": "sqlite",
"id": "d42caef6-2cfa-48bf-a8b3-46de4af43f47",
"prevId": "c2e44835-b24a-4410-babf-887a82a4568e",
"tables": {
"agent_run_actions": {
"name": "agent_run_actions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"run_id": {
"name": "run_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"verb": {
"name": "verb",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"entity_type": {
"name": "entity_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"entity_id": {
"name": "entity_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entity_title": {
"name": "entity_title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"agent_runs": {
"name": "agent_runs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'running'"
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"clients": {
"name": "clients",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"industry": {
"name": "industry",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"note_edits": {
"name": "note_edits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"anchor_before": {
"name": "anchor_before",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"anchor_text": {
"name": "anchor_text",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"proposed_content": {
"name": "proposed_content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"run_id": {
"name": "run_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reasoning": {
"name": "reasoning",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"resolved_at": {
"name": "resolved_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"notes": {
"name": "notes",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"ai_summary": {
"name": "ai_summary",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ai_summary_updated_at": {
"name": "ai_summary_updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"projects": {
"name": "projects",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"ai_summary": {
"name": "ai_summary",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_brief_chats": {
"name": "task_brief_chats",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_error": {
"name": "is_error",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_briefings": {
"name": "task_briefings",
"columns": {
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"briefing_markdown": {
"name": "briefing_markdown",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"canvas_draft": {
"name": "canvas_draft",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"canvas_kind": {
"name": "canvas_kind",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"citations": {
"name": "citations",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source_task_hash": {
"name": "source_task_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generated_at": {
"name": "generated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"model_version": {
"name": "model_version",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_comments": {
"name": "task_comments",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tasks": {
"name": "tasks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'todo'"
},
"priority": {
"name": "priority",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'medium'"
},
"assignee": {
"name": "assignee",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"due_date": {
"name": "due_date",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_ai_suggested": {
"name": "is_ai_suggested",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"timeline_event_dependencies": {
"name": "timeline_event_dependencies",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"from_event_id": {
"name": "from_event_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"to_event_id": {
"name": "to_event_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"timeline_events": {
"name": "timeline_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"end_date": {
"name": "end_date",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'milestone'"
},
"is_completed": {
"name": "is_completed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"is_ai_suggested": {
"name": "is_ai_suggested",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,831 @@
{
"version": "6",
"dialect": "sqlite",
"id": "8127cd67-44d0-41e8-a146-12eb1311c6c1",
"prevId": "d42caef6-2cfa-48bf-a8b3-46de4af43f47",
"tables": {
"agent_run_actions": {
"name": "agent_run_actions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"run_id": {
"name": "run_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"verb": {
"name": "verb",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"entity_type": {
"name": "entity_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"entity_id": {
"name": "entity_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entity_title": {
"name": "entity_title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"agent_runs": {
"name": "agent_runs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'running'"
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"clients": {
"name": "clients",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"industry": {
"name": "industry",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"note_edits": {
"name": "note_edits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"anchor_before": {
"name": "anchor_before",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"anchor_text": {
"name": "anchor_text",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"proposed_content": {
"name": "proposed_content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"run_id": {
"name": "run_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reasoning": {
"name": "reasoning",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"resolved_at": {
"name": "resolved_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"notes": {
"name": "notes",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"ai_summary": {
"name": "ai_summary",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ai_summary_updated_at": {
"name": "ai_summary_updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"projects": {
"name": "projects",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"ai_summary": {
"name": "ai_summary",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_attachments": {
"name": "task_attachments",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"size_bytes": {
"name": "size_bytes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"stored_path": {
"name": "stored_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_brief_chats": {
"name": "task_brief_chats",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_error": {
"name": "is_error",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_briefings": {
"name": "task_briefings",
"columns": {
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"briefing_markdown": {
"name": "briefing_markdown",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"canvas_draft": {
"name": "canvas_draft",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"canvas_kind": {
"name": "canvas_kind",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"citations": {
"name": "citations",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source_task_hash": {
"name": "source_task_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generated_at": {
"name": "generated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"model_version": {
"name": "model_version",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_comments": {
"name": "task_comments",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tasks": {
"name": "tasks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'todo'"
},
"priority": {
"name": "priority",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'medium'"
},
"assignee": {
"name": "assignee",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"due_date": {
"name": "due_date",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"estimate": {
"name": "estimate",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_ai_suggested": {
"name": "is_ai_suggested",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"timeline_event_dependencies": {
"name": "timeline_event_dependencies",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"from_event_id": {
"name": "from_event_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"to_event_id": {
"name": "to_event_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"timeline_events": {
"name": "timeline_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"end_date": {
"name": "end_date",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'milestone'"
},
"is_completed": {
"name": "is_completed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"is_ai_suggested": {
"name": "is_ai_suggested",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,934 @@
{
"version": "6",
"dialect": "sqlite",
"id": "db432653-ac1d-40f4-b7eb-216d054ae191",
"prevId": "8127cd67-44d0-41e8-a146-12eb1311c6c1",
"tables": {
"agent_run_actions": {
"name": "agent_run_actions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"run_id": {
"name": "run_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"verb": {
"name": "verb",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"entity_type": {
"name": "entity_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"entity_id": {
"name": "entity_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entity_title": {
"name": "entity_title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"agent_runs": {
"name": "agent_runs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'running'"
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"clients": {
"name": "clients",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"industry": {
"name": "industry",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"note_edits": {
"name": "note_edits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"anchor_before": {
"name": "anchor_before",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"anchor_text": {
"name": "anchor_text",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"proposed_content": {
"name": "proposed_content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"run_id": {
"name": "run_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reasoning": {
"name": "reasoning",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"resolved_at": {
"name": "resolved_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"notes": {
"name": "notes",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"ai_summary": {
"name": "ai_summary",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ai_summary_updated_at": {
"name": "ai_summary_updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"project_folder_files": {
"name": "project_folder_files",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"relative_path": {
"name": "relative_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ext": {
"name": "ext",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"size_bytes": {
"name": "size_bytes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mtime_ms": {
"name": "mtime_ms",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"summary_updated_at": {
"name": "summary_updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"projects": {
"name": "projects",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"ai_summary": {
"name": "ai_summary",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"folder_path": {
"name": "folder_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"folder_last_scanned_at": {
"name": "folder_last_scanned_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"folder_last_scan_status": {
"name": "folder_last_scan_status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'idle'"
},
"folder_total_files": {
"name": "folder_total_files",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_attachments": {
"name": "task_attachments",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"size_bytes": {
"name": "size_bytes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"stored_path": {
"name": "stored_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_brief_chats": {
"name": "task_brief_chats",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_error": {
"name": "is_error",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_briefings": {
"name": "task_briefings",
"columns": {
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"briefing_markdown": {
"name": "briefing_markdown",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"canvas_draft": {
"name": "canvas_draft",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"canvas_kind": {
"name": "canvas_kind",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"citations": {
"name": "citations",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source_task_hash": {
"name": "source_task_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generated_at": {
"name": "generated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"model_version": {
"name": "model_version",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_comments": {
"name": "task_comments",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tasks": {
"name": "tasks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'todo'"
},
"priority": {
"name": "priority",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'medium'"
},
"assignee": {
"name": "assignee",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"due_date": {
"name": "due_date",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"estimate": {
"name": "estimate",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_ai_suggested": {
"name": "is_ai_suggested",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"timeline_event_dependencies": {
"name": "timeline_event_dependencies",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"from_event_id": {
"name": "from_event_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"to_event_id": {
"name": "to_event_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"timeline_events": {
"name": "timeline_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"end_date": {
"name": "end_date",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'milestone'"
},
"is_completed": {
"name": "is_completed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"is_ai_suggested": {
"name": "is_ai_suggested",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1777233385010,
"tag": "0000_broad_dust",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1777499571580,
"tag": "0001_boring_the_leader",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1777882122765,
"tag": "0002_giant_karnak",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1777889091889,
"tag": "0003_shiny_karma",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1778238659431,
"tag": "0004_right_alex_power",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1778579196669,
"tag": "0005_slim_baron_strucker",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1778777130582,
"tag": "0006_misty_cammi",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1747353600000,
"tag": "0007_scouts_rename",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1747440000000,
"tag": "0008_scout_suggestions",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,69 @@
/**
* Notes AI summary backfill.
*
* On startup, scans notes with a null ai_summary and generates summaries
* via the backend `POST /api/v1/scouts/notes/summarize` endpoint.
*
* - Throttled to 1 request/second to avoid rate-limiting.
* - Idempotent: notes that already have an aiSummary are skipped.
* - Offline-safe: if the backend is unreachable the run is skipped entirely;
* the next startup will retry.
*/
import { eq, isNull } from 'drizzle-orm';
import { getDb } from './index';
import { notes } from './schema';
import { getBackendClient } from '../api/backend-client';
const THROTTLE_MS = 1_000;
export async function backfillNoteSummaries(): Promise<void> {
const client = getBackendClient();
const isOnline = await client.isOnline().catch(() => false);
if (!isOnline) {
console.log('[NotesBackfill] Backend offline — skipping aiSummary backfill.');
return;
}
const pending = getDb()
.select({ id: notes.id, title: notes.title, content: notes.content })
.from(notes)
.where(isNull(notes.aiSummary))
.all();
if (pending.length === 0) {
console.log('[NotesBackfill] All notes have aiSummary — nothing to backfill.');
return;
}
console.log(`[NotesBackfill] Generating aiSummary for ${pending.length} note(s)…`);
let success = 0;
for (let i = 0; i < pending.length; i++) {
const note = pending[i]!;
try {
const result = await client.proxyPost<{ summary: string }>(
'/api/v1/scouts/notes/summarize',
{ title: note.title, content: note.content },
);
const summary = result.summary?.trim() ?? '';
if (summary) {
getDb()
.update(notes)
.set({ aiSummary: summary, aiSummaryUpdatedAt: Date.now() })
.where(eq(notes.id, note.id))
.run();
success++;
}
} catch (err) {
console.warn(`[NotesBackfill] Failed for note ${note.id}:`, err);
}
if (i < pending.length - 1) {
await new Promise<void>((r) => setTimeout(r, THROTTLE_MS));
}
}
console.log(`[NotesBackfill] Done: ${success}/${pending.length} summaries generated.`);
}

View File

@@ -16,6 +16,12 @@ export const projects = sqliteTable('projects', {
status: text('status', { enum: ['active', 'archived'] }).notNull().default('active'),
aiSummary: text('ai_summary'),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
folderPath: text('folder_path'),
folderLastScannedAt: integer('folder_last_scanned_at', { mode: 'number' }),
folderLastScanStatus: text('folder_last_scan_status', {
enum: ['idle', 'scanning', 'error'],
}).default('idle'),
folderTotalFiles: integer('folder_total_files', { mode: 'number' }).notNull().default(0),
});
export const tasks = sqliteTable('tasks', {
@@ -27,18 +33,29 @@ export const tasks = sqliteTable('tasks', {
priority: text('priority').notNull().default('medium'),
assignee: text('assignee'),
dueDate: integer('due_date', { mode: 'number' }),
estimate: integer('estimate', { mode: 'number' }),
isAiSuggested: integer('is_ai_suggested', { mode: 'number' }).notNull().default(0),
isApproved: integer('is_approved', { mode: 'number' }).notNull().default(1),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
completedAt: integer('completed_at', { mode: 'number' }),
});
export const checkpoints = sqliteTable('checkpoints', {
export const timelineEvents = sqliteTable('timeline_events', {
id: text('id').primaryKey(),
projectId: text('project_id').notNull(),
projectId: text('project_id'),
title: text('title').notNull(),
date: integer('date', { mode: 'number' }).notNull(),
endDate: integer('end_date', { mode: 'number' }),
type: text('type', { enum: ['milestone', 'checkpoint', 'activity'] }).notNull().default('milestone'),
isCompleted: integer('is_completed', { mode: 'number' }).notNull().default(0),
isAiSuggested: integer('is_ai_suggested', { mode: 'number' }).notNull().default(0),
isApproved: integer('is_approved', { mode: 'number' }).notNull().default(0),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
completedAt: integer('completed_at', { mode: 'number' }),
});
export const timelineEventDependencies = sqliteTable('timeline_event_dependencies', {
id: text('id').primaryKey(),
fromEventId: text('from_event_id').notNull(),
toEventId: text('to_event_id').notNull(),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
});
@@ -47,10 +64,42 @@ export const notes = sqliteTable('notes', {
projectId: text('project_id'),
title: text('title').notNull(),
content: text('content').notNull().default(''),
aiSummary: text('ai_summary'),
aiSummaryUpdatedAt: integer('ai_summary_updated_at', { mode: 'number' }),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
});
export const projectFolderFiles = sqliteTable('project_folder_files', {
id: text('id').primaryKey(),
projectId: text('project_id').notNull(),
relativePath: text('relative_path').notNull(),
ext: text('ext').notNull(),
kind: text('kind', { enum: ['text', 'image', 'pdf', 'docx', 'csv', 'skipped', 'error'] }).notNull(),
sizeBytes: integer('size_bytes', { mode: 'number' }).notNull(),
mtimeMs: integer('mtime_ms', { mode: 'number' }).notNull(),
summary: text('summary'),
summaryUpdatedAt: integer('summary_updated_at', { mode: 'number' }),
});
export type ProjectFolderFile = InferSelectModel<typeof projectFolderFiles>;
export type NewProjectFolderFile = InferInsertModel<typeof projectFolderFiles>;
export const noteEdits = sqliteTable('note_edits', {
id: text('id').primaryKey(),
noteId: text('note_id').notNull(),
type: text('type', { enum: ['append', 'insert', 'replace'] }).notNull(),
anchorBefore: text('anchor_before'),
anchorText: text('anchor_text'),
proposedContent: text('proposed_content').notNull(),
status: text('status', { enum: ['pending', 'approved', 'rejected'] }).notNull().default('pending'),
agentId: text('agent_id'),
runId: text('run_id'),
reasoning: text('reasoning'),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
resolvedAt: integer('resolved_at', { mode: 'number' }),
});
export const taskComments = sqliteTable('task_comments', {
id: text('id').primaryKey(),
taskId: text('task_id').notNull(),
@@ -59,6 +108,16 @@ export const taskComments = sqliteTable('task_comments', {
createdAt: integer('created_at', { mode: 'number' }).notNull(),
});
export const taskAttachments = sqliteTable('task_attachments', {
id: text('id').primaryKey(),
taskId: text('task_id').notNull(),
filename: text('filename').notNull(),
mimeType: text('mime_type'),
sizeBytes: integer('size_bytes', { mode: 'number' }).notNull(),
storedPath: text('stored_path').notNull(),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
});
// Inferred TypeScript types — no manual duplication
export type Client = InferSelectModel<typeof clients>;
export type NewClient = InferInsertModel<typeof clients>;
@@ -69,11 +128,116 @@ export type NewProject = InferInsertModel<typeof projects>;
export type Task = InferSelectModel<typeof tasks>;
export type NewTask = InferInsertModel<typeof tasks>;
export type Checkpoint = InferSelectModel<typeof checkpoints>;
export type NewCheckpoint = InferInsertModel<typeof checkpoints>;
export type Note = InferSelectModel<typeof notes>;
export type NewNote = InferInsertModel<typeof notes>;
export type TaskComment = InferSelectModel<typeof taskComments>;
export type NewTaskComment = InferInsertModel<typeof taskComments>;
export type TaskAttachment = InferSelectModel<typeof taskAttachments>;
export type NewTaskAttachment = InferInsertModel<typeof taskAttachments>;
export type TimelineEvent = InferSelectModel<typeof timelineEvents>;
export type NewTimelineEvent = InferInsertModel<typeof timelineEvents>;
export type TimelineEventDependency = InferSelectModel<typeof timelineEventDependencies>;
export type NewTimelineEventDependency = InferInsertModel<typeof timelineEventDependencies>;
export const taskBriefings = sqliteTable('task_briefings', {
taskId: text('task_id').primaryKey(),
briefingMarkdown: text('briefing_markdown').notNull(),
canvasDraft: text('canvas_draft'),
canvasKind: text('canvas_kind'),
citations: text('citations'),
sourceTaskHash: text('source_task_hash').notNull(),
generatedAt: integer('generated_at', { mode: 'number' }).notNull(),
modelVersion: text('model_version'),
});
export type TaskBriefing = InferSelectModel<typeof taskBriefings>;
export type NewTaskBriefing = InferInsertModel<typeof taskBriefings>;
export const taskBriefChats = sqliteTable('task_brief_chats', {
id: text('id').primaryKey(),
taskId: text('task_id').notNull(),
role: text('role', { enum: ['user', 'assistant'] }).notNull(),
content: text('content').notNull(),
isError: integer('is_error', { mode: 'boolean' }).notNull().default(false),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
});
export type TaskBriefChat = InferSelectModel<typeof taskBriefChats>;
export type NewTaskBriefChat = InferInsertModel<typeof taskBriefChats>;
export const scoutRuns = sqliteTable('scout_runs', {
id: text('id').primaryKey(),
scoutId: text('scout_id').notNull(),
status: text('status', { enum: ['running', 'completed', 'failed', 'partial'] }).notNull().default('running'),
startedAt: integer('started_at', { mode: 'number' }).notNull(),
completedAt: integer('completed_at', { mode: 'number' }),
});
export const scoutRunActions = sqliteTable('scout_run_actions', {
id: text('id').primaryKey(),
runId: text('run_id').notNull(),
scoutId: text('scout_id').notNull(),
/** 'created' | 'updated' | 'deleted' | 'commented' */
verb: text('verb').notNull(),
/** 'task' | 'note' | 'project' | 'timeline' | 'comment' */
entityType: text('entity_type').notNull(),
entityId: text('entity_id'),
entityTitle: text('entity_title'),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
});
export type ScoutRun = InferSelectModel<typeof scoutRuns>;
export type NewScoutRun = InferInsertModel<typeof scoutRuns>;
export type ScoutRunAction = InferSelectModel<typeof scoutRunActions>;
export type NewScoutRunAction = InferInsertModel<typeof scoutRunActions>;
export type NoteEdit = InferSelectModel<typeof noteEdits>;
export type NewNoteEdit = InferInsertModel<typeof noteEdits>;
export const aiChatSessions = sqliteTable('ai_chat_sessions', {
id: text('id').primaryKey(),
channel: text('channel', { enum: ['home', 'contextual'] }).notNull(),
title: text('title'),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
lastScope: text('last_scope'),
});
export const aiChatMessages = sqliteTable('ai_chat_messages', {
id: text('id').primaryKey(),
sessionId: text('session_id').notNull(),
role: text('role', { enum: ['user', 'assistant', 'system'] }).notNull(),
content: text('content').notNull(),
toolCalls: text('tool_calls'),
toolResults: text('tool_results'),
scope: text('scope'),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
});
export type AiChatSession = InferSelectModel<typeof aiChatSessions>;
export type NewAiChatSession = InferInsertModel<typeof aiChatSessions>;
export type AiChatMessage = InferSelectModel<typeof aiChatMessages>;
export type NewAiChatMessage = InferInsertModel<typeof aiChatMessages>;
export const scoutSuggestions = sqliteTable('scout_suggestions', {
id: text().primaryKey(),
scoutId: text('scout_id').notNull(),
sourceType: text('source_type').notNull(),
sourceMsgRef: text('source_msg_ref').notNull(),
category: text().notNull(), // "unprocessed" until Phase 4
payload: text(), // JSON, populated by Phase 4
rawSubject: text('raw_subject'),
rawSnippet: text('raw_snippet'),
status: text().notNull(), // pending | approved | rejected | expired
proposedAt: integer('proposed_at').notNull(),
resolvedAt: integer('resolved_at'),
resolvedEntityType: text('resolved_entity_type'),
resolvedEntityId: text('resolved_entity_id'),
});
export type ScoutSuggestion = InferSelectModel<typeof scoutSuggestions>;
export type NewScoutSuggestion = InferInsertModel<typeof scoutSuggestions>;

View File

@@ -1,182 +0,0 @@
import * as lancedb from 'vectordb';
import { app } from 'electron';
import path from 'node:path';
import { getDb } from './index';
import { notes } from './schema';
import { embedText } from '../ai/embeddings';
interface NoteRecord {
id: string;
/** Empty string when the note has no project (Arrow string fields don't cleanly handle null) */
projectId: string;
content: string;
vector: number[];
}
export interface SearchResult {
id: string;
projectId: string;
content: string;
_distance: number;
}
let conn: lancedb.Connection | null = null;
/**
* Initialize the LanceDB connection. Must be called before any other
* function in this module. Vector data is stored at userData/vectors/.
*/
export async function initVectorDb(): Promise<void> {
const vectorPath = path.join(app.getPath('userData'), 'vectors');
conn = await lancedb.connect(vectorPath);
console.log('[VectorDB] Connected at:', vectorPath);
}
function getConn(): lancedb.Connection {
if (!conn) throw new Error('[VectorDB] Not initialized. Call initVectorDb() first.');
return conn;
}
/**
* Embed note content and upsert the record into the LanceDB 'notes' table.
*
* Upsert strategy: delete-then-add.
* table.delete(where) is a no-op when no rows match, so this is safe for
* both first-time inserts and subsequent updates.
*
* On the very first call when the table does not yet exist, createTable
* infers the Arrow schema from the initial record.
*
* Throws on error — callers fire-and-forget via .catch().
*/
export async function upsertNoteEmbedding(
noteId: string,
projectId: string | null,
content: string,
): Promise<void> {
const c = getConn();
const vector = await embedText(content);
const record: NoteRecord = {
id: noteId,
projectId: projectId ?? '',
content,
vector,
};
const tableNames = await c.tableNames();
if (!tableNames.includes('notes')) {
// First embedding: createTable infers the Arrow schema from this record.
// The vector dimension (1536 for text-embedding-3-small) is baked in here.
await c.createTable('notes', [record]);
console.log('[VectorDB] Created notes table');
return;
}
const table = await c.openTable<NoteRecord>('notes');
// Note IDs are UUID v4 — only [0-9a-f-] chars, no SQL injection risk.
await table.delete(`id = '${noteId}'`);
await table.add([record]);
}
/**
* Store a note embedding with a **pre-computed** vector (no embedding API call).
* Used by the DrizzleExecutor when the backend sends a `vector_upsert` tool call
* with the vector already embedded server-side.
*
* @see AI_REFACTOR_PLAN.md — Phase 1, Step 1.6 (embedding migration)
*/
export async function upsertWithVector(
noteId: string,
projectId: string | null,
content: string,
vector: number[],
): Promise<void> {
const c = getConn();
const record: NoteRecord = {
id: noteId,
projectId: projectId ?? '',
content,
vector,
};
const tableNames = await c.tableNames();
if (!tableNames.includes('notes')) {
await c.createTable('notes', [record]);
console.log('[VectorDB] Created notes table (via upsertWithVector)');
return;
}
const table = await c.openTable<NoteRecord>('notes');
await table.delete(`id = '${noteId}'`);
await table.add([record]);
}
/**
* On first startup, check if the LanceDB 'notes' table exists.
* If not, embed all existing SQLite notes and populate LanceDB.
*
* Per-note errors are caught and logged; a single failure does not
* abort the remaining notes.
*/
export async function migrateNotesIfNeeded(): Promise<void> {
const c = getConn();
const tableNames = await c.tableNames();
if (tableNames.includes('notes')) {
console.log('[VectorDB] Notes table exists, skipping migration');
return;
}
const allNotes = getDb().select().from(notes).all();
if (allNotes.length === 0) {
console.log('[VectorDB] No existing notes to migrate');
return;
}
console.log(`[VectorDB] Migrating ${allNotes.length} notes...`);
let successCount = 0;
for (const note of allNotes) {
try {
const embeddingText = `${note.title}\n\n${note.content}`;
await upsertNoteEmbedding(note.id, note.projectId ?? null, embeddingText);
successCount++;
} catch (err) {
console.error(`[VectorDB] Failed to embed note ${note.id} during migration:`, err);
}
}
console.log(`[VectorDB] Migration complete: ${successCount}/${allNotes.length} notes embedded`);
}
/**
* Embed the query string and perform a similarity search across all notes
* in the LanceDB 'notes' table. Returns up to `limit` results sorted by
* distance (closest first).
*
* Returns an empty array if the notes table does not exist yet.
*/
export async function searchNotes(query: string, limit = 5): Promise<SearchResult[]> {
const c = getConn();
const tableNames = await c.tableNames();
if (!tableNames.includes('notes')) {
return [];
}
const queryVector = await embedText(query);
const table = await c.openTable('notes');
const results = await table.search(queryVector).limit(limit).execute();
return results.map((r) => ({
id: r.id as string,
projectId: r.projectId as string,
content: r.content as string,
_distance: r._distance as number,
}));
}

View File

@@ -0,0 +1,21 @@
/** File-type whitelists & size caps for project folder indexing. */
export const TEXT_EXTS = new Set([
'.md', '.txt', '.rst', '.adoc',
'.json', '.yaml', '.yml', '.toml', '.ini', '.csv', '.tsv',
'.html', '.htm', '.xml',
'.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs',
'.py', '.rb', '.go', '.rs', '.java', '.kt', '.swift',
'.c', '.h', '.cpp', '.hpp', '.cs', '.php', '.sh', '.ps1',
'.css', '.scss', '.sass',
'.sql',
]);
export const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
export const PDF_EXTS = new Set(['.pdf']);
export const DOCX_EXTS = new Set(['.docx']);
export const MAX_TEXT_FILE_BYTES = 1 * 1024 * 1024; // 1 MB
export const MAX_IMAGE_FILE_BYTES = 5 * 1024 * 1024; // 5 MB
export const INDEX_BATCH_SIZE = 5;

View File

@@ -0,0 +1,27 @@
// adiuvAI/src/main/files/daily-rescan.ts
import { getDb } from '../db';
import { projects } from '../db/schema';
import { sql, and, isNotNull } from 'drizzle-orm';
import { startIndexSession } from './indexer';
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
export async function runDailyRescan(): Promise<void> {
const cutoff = Date.now() - ONE_DAY_MS;
const stale = getDb()
.select()
.from(projects)
.where(
and(
isNotNull(projects.folderPath),
sql`(${projects.folderLastScannedAt} IS NULL OR ${projects.folderLastScannedAt} < ${cutoff})`,
),
)
.all();
for (const p of stale) {
if (p.folderLastScanStatus === 'scanning') continue;
// Fire-and-forget; no UI listener.
// eslint-disable-next-line @typescript-eslint/no-empty-function
void startIndexSession(p.id, () => {});
}
}

222
src/main/files/indexer.ts Normal file
View File

@@ -0,0 +1,222 @@
/**
* Folder index session orchestrator.
*
* Walks a folder via scanner.ts, sends batches over WS to the backend, applies
* returned summaries to projectFolderFiles, drives progress callbacks.
*/
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { getDb } from '../db';
import { projects, projectFolderFiles } from '../db/schema';
import { eq, and } from 'drizzle-orm';
import { scanFolder, type ScannedFile } from './scanner';
import { INDEX_BATCH_SIZE } from './constants';
import { getBackendClient } from '../api/backend-client';
export interface IndexProgress {
sessionId: string;
processed: number;
total: number;
status: 'starting' | 'scanning' | 'cancelled' | 'completed' | 'quota_exceeded' | 'error';
error?: string;
}
export type ProgressListener = (p: IndexProgress) => void;
async function readForIndex(
folderPath: string,
f: ScannedFile,
): Promise<{ content: string; mime?: string }> {
const abs = path.join(folderPath, f.relativePath);
if (f.kind === 'image') {
const buf = await readFile(abs);
const ext = f.ext.toLowerCase();
const mime =
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
return { content: buf.toString('base64'), mime };
}
if (f.kind === 'text') {
return { content: await readFile(abs, 'utf-8') };
}
// pdf / docx: read as binary, base64. Server is responsible for extraction.
const buf = await readFile(abs);
return { content: buf.toString('base64') };
}
export async function startIndexSession(
projectId: string,
onProgress: ProgressListener,
): Promise<{ sessionId: string; cancel: () => void }> {
const sessionId = randomUUID();
const db = getDb();
const proj = db.select().from(projects).where(eq(projects.id, projectId)).get();
if (!proj || !proj.folderPath) {
onProgress({ sessionId, processed: 0, total: 0, status: 'error', error: 'No folder linked' });
// eslint-disable-next-line @typescript-eslint/no-empty-function
return { sessionId, cancel: () => {} };
}
db.update(projects)
.set({ folderLastScanStatus: 'scanning' })
.where(eq(projects.id, projectId))
.run();
onProgress({ sessionId, processed: 0, total: 0, status: 'scanning' });
const delta = await scanFolder(projectId, proj.folderPath);
// Filter out 'skipped' files — they are too large to index and must not be sent
const toIndex = [
...delta.newFiles.filter((f) => f.kind !== 'skipped'),
...delta.changedFiles.filter((f) => f.kind !== 'skipped'),
];
const total = toIndex.length;
for (const rel of delta.deletedRelPaths) {
db.delete(projectFolderFiles)
.where(
and(
eq(projectFolderFiles.projectId, projectId),
eq(projectFolderFiles.relativePath, rel),
),
)
.run();
}
if (total === 0) {
db.update(projects)
.set({
folderLastScanStatus: 'idle',
folderLastScannedAt: Date.now(),
folderTotalFiles: delta.unchangedCount,
})
.where(eq(projects.id, projectId))
.run();
onProgress({ sessionId, processed: 0, total: 0, status: 'completed' });
// eslint-disable-next-line @typescript-eslint/no-empty-function
return { sessionId, cancel: () => {} };
}
const backend = getBackendClient();
let processed = 0;
let cancelled = false;
const finalize = (status: IndexProgress['status'], error?: string): void => {
db.update(projects)
.set({
folderLastScanStatus:
status === 'completed' || status === 'cancelled' ? 'idle' : 'error',
folderLastScannedAt: Date.now(),
folderTotalFiles: delta.unchangedCount + processed,
})
.where(eq(projects.id, projectId))
.run();
onProgress({ sessionId, processed, total, status, error });
};
backend.registerIndexSession(sessionId, {
onFileResult: ({ relPath, summary, error }) => {
if (error) return;
const f = toIndex.find((x) => x.relativePath === relPath);
if (!f) return;
const now = Date.now();
// SELECT-then-INSERT-or-UPDATE: no unique index on (projectId, relativePath)
const existing = db
.select()
.from(projectFolderFiles)
.where(
and(
eq(projectFolderFiles.projectId, projectId),
eq(projectFolderFiles.relativePath, f.relativePath),
),
)
.get();
if (existing) {
db.update(projectFolderFiles)
.set({
mtimeMs: f.mtimeMs,
sizeBytes: f.sizeBytes,
kind: f.kind,
summary: summary ?? null,
summaryUpdatedAt: now,
})
.where(eq(projectFolderFiles.id, existing.id))
.run();
} else {
db.insert(projectFolderFiles)
.values({
id: randomUUID(),
projectId,
relativePath: f.relativePath,
ext: f.ext,
kind: f.kind,
sizeBytes: f.sizeBytes,
mtimeMs: f.mtimeMs,
summary: summary ?? null,
summaryUpdatedAt: now,
})
.run();
}
},
onProgress: ({ processed: p, total: t }) => {
processed = p;
onProgress({ sessionId, processed: p, total: t, status: 'scanning' });
},
onDone: (status) => {
finalize(
status === 'completed'
? 'completed'
: status === 'cancelled'
? 'cancelled'
: status === 'quota_exceeded'
? 'quota_exceeded'
: 'error',
);
},
});
try {
backend.sendIndexSessionStart(sessionId, projectId, total);
} catch (err) {
finalize('error', err instanceof Error ? err.message : 'WS send failed');
// eslint-disable-next-line @typescript-eslint/no-empty-function
return { sessionId, cancel: () => {} };
}
// Send batches (skipped files already excluded from toIndex)
for (let i = 0; i < toIndex.length; i += INDEX_BATCH_SIZE) {
if (cancelled) break;
const batch = toIndex.slice(i, i + INDEX_BATCH_SIZE);
const payload = await Promise.all(
batch.map(async (f) => {
const { content, mime } = await readForIndex(proj.folderPath!, f);
return {
relPath: f.relativePath,
kind: f.kind as 'text' | 'image' | 'pdf' | 'docx',
content,
ext: f.ext,
mime,
sizeBytes: f.sizeBytes,
mtimeMs: f.mtimeMs,
};
}),
);
try {
backend.sendIndexFileBatch(sessionId, payload);
} catch (err) {
finalize('error', err instanceof Error ? err.message : 'WS send failed');
// eslint-disable-next-line @typescript-eslint/no-empty-function
return { sessionId, cancel: () => {} };
}
}
const cancel = (): void => {
cancelled = true;
backend.sendIndexSessionCancel(sessionId);
};
return { sessionId, cancel };
}

95
src/main/files/scanner.ts Normal file
View File

@@ -0,0 +1,95 @@
/** Filesystem scanner — walks a directory, filters by whitelist, computes delta vs DB manifest. */
import { readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { getDb } from '../db';
import { projectFolderFiles } from '../db/schema';
import { eq } from 'drizzle-orm';
import {
TEXT_EXTS, IMAGE_EXTS, PDF_EXTS, DOCX_EXTS,
MAX_TEXT_FILE_BYTES, MAX_IMAGE_FILE_BYTES,
} from './constants';
export type FileKind = 'text' | 'image' | 'pdf' | 'docx' | 'skipped';
export interface ScannedFile {
relativePath: string;
ext: string;
kind: FileKind;
sizeBytes: number;
mtimeMs: number;
}
export interface ScanDelta {
newFiles: ScannedFile[];
changedFiles: ScannedFile[];
unchangedCount: number;
deletedRelPaths: string[];
}
function classify(ext: string, sizeBytes: number): FileKind | null {
const e = ext.toLowerCase();
if (TEXT_EXTS.has(e)) return sizeBytes <= MAX_TEXT_FILE_BYTES ? 'text' : 'skipped';
if (IMAGE_EXTS.has(e)) return sizeBytes <= MAX_IMAGE_FILE_BYTES ? 'image' : 'skipped';
if (PDF_EXTS.has(e)) return sizeBytes <= MAX_TEXT_FILE_BYTES ? 'pdf' : 'skipped';
if (DOCX_EXTS.has(e)) return sizeBytes <= MAX_TEXT_FILE_BYTES ? 'docx' : 'skipped';
return null; // not indexable
}
async function walk(root: string): Promise<ScannedFile[]> {
const out: ScannedFile[] = [];
async function recurse(dir: string) {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return; // permission denied — skip silently
}
for (const e of entries) {
if (e.name.startsWith('.')) continue; // skip dot dirs / files
if (e.name === 'node_modules') continue; // common noise
const full = path.join(dir, e.name);
if (e.isDirectory()) {
await recurse(full);
} else if (e.isFile()) {
let s;
try { s = await stat(full); } catch { continue; }
const ext = path.extname(e.name);
const kind = classify(ext, s.size);
if (kind === null) continue;
out.push({
relativePath: path.relative(root, full),
ext,
kind,
sizeBytes: s.size,
mtimeMs: Math.floor(s.mtimeMs),
});
}
}
}
await recurse(root);
return out;
}
export async function scanFolder(projectId: string, folderPath: string): Promise<ScanDelta> {
const scanned = await walk(folderPath);
const existing = getDb()
.select()
.from(projectFolderFiles)
.where(eq(projectFolderFiles.projectId, projectId))
.all();
const existingMap = new Map(existing.map(r => [r.relativePath, r]));
const newFiles: ScannedFile[] = [];
const changedFiles: ScannedFile[] = [];
let unchanged = 0;
for (const f of scanned) {
const prev = existingMap.get(f.relativePath);
if (!prev) newFiles.push(f);
else if (prev.mtimeMs !== f.mtimeMs || prev.sizeBytes !== f.sizeBytes) changedFiles.push(f);
else unchanged++;
existingMap.delete(f.relativePath);
}
const deletedRelPaths = Array.from(existingMap.keys());
return { newFiles, changedFiles, unchangedCount: unchanged, deletedRelPaths };
}

View File

@@ -1,27 +1,97 @@
import { app, BrowserWindow } from 'electron';
import { app, BrowserWindow, ipcMain, dialog } from 'electron';
import path from 'node:path';
import started from 'electron-squirrel-startup';
import { initDb } from './db';
import { appRouter } from './router';
import { createIPCHandler } from './ipc';
import { initAI } from './ai/provider';
import { initVectorDb, migrateNotesIfNeeded } from './db/vectordb';
// Import to trigger provider registration before initAI() runs
import './ai/copilot';
import { getAuthManager } from './auth/auth-manager';
import { getBackendClient } from './api/backend-client';
import { getStore } from './store';
import { startBriefScheduler, stopBriefScheduler } from './ai/orchestrator';
import { startScoutScheduler, stopScoutScheduler } from './scouts/scout-scheduler';
import { backfillNoteSummaries } from './db/notes-backfill';
import { runDailyRescan } from './files/daily-rescan';
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (started) {
app.quit();
}
// ---------------------------------------------------------------------------
// Single-instance lock + deep link (OAuth callback via adiuvai://)
// ---------------------------------------------------------------------------
// In dev, Electron is launched as: `electron . ` (or via electron-forge).
// setAsDefaultProtocolClient on Windows/Linux requires the path to the exe.
if (process.defaultApp) {
// Dev: electron.exe is the "app" — pass the script path as the second arg
// so that OS-registered links include it and second-instance receives the URL.
app.setAsDefaultProtocolClient('adiuvai', process.execPath, [path.resolve(process.argv[1] ?? '.')]);
} else {
app.setAsDefaultProtocolClient('adiuvai');
}
/**
* Extract and dispatch an adiuvai:// deep link URL.
* Delegates to AuthManager so the pending OAuth promise is resolved.
* Also handles scout-specific OAuth callbacks (e.g. Gmail connector setup).
*/
function handleDeepLink(url: string): void {
if (url.startsWith('adiuvai://oauth/callback')) {
void getAuthManager().handleOAuthCallback(url);
return;
}
// Scout Gmail OAuth callback: adiuvai://scout/oauth/gmail/callback?code=...&state=...
if (url.startsWith('adiuvai://scout/oauth/gmail/callback')) {
const parsed = new URL(url);
const code = parsed.searchParams.get('code');
const state = parsed.searchParams.get('state');
if (code && state) {
const windows = BrowserWindow.getAllWindows();
windows[0]?.webContents.send('scout:gmailOAuthCallback', { code, state });
}
}
}
// Windows / Linux: a second instance is launched with the deep link as an argv.
// We prevent the second instance and redirect the URL to the first instance.
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
// Another instance already running — hand off and exit.
app.quit();
} else {
app.on('second-instance', (_event, argv) => {
// On Windows the URL is the last argument (e.g. adiuvai://oauth/callback?...)
const url = argv.find((arg) => arg.startsWith('adiuvai://'));
if (url) handleDeepLink(url);
// Bring the existing window to focus.
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
const win = windows[0]!;
if (win.isMinimized()) win.restore();
win.focus();
}
});
}
// macOS: the OS delivers the URL via this event (no second instance spawned).
app.on('open-url', (event, url) => {
event.preventDefault();
handleDeepLink(url);
});
const createWindow = (): BrowserWindow => {
// Create the browser window.
const iconPath = path.join(__dirname, '../../assets/logo/logo-icon.png');
const mainWindow = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 900,
minHeight: 600,
titleBarStyle: 'hiddenInset',
icon: iconPath,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
@@ -46,6 +116,20 @@ const createWindow = (): BrowserWindow => {
return mainWindow;
};
// ---------------------------------------------------------------------------
// Dialog IPC — file/folder picker
// ---------------------------------------------------------------------------
ipcMain.handle('dialog:showOpenDialog', (_event, options: Electron.OpenDialogOptions) =>
dialog.showOpenDialog(options),
);
// ---------------------------------------------------------------------------
// Contextual sidebar — scope update IPC handler (M4.7)
// ---------------------------------------------------------------------------
ipcMain.handle('ai:contextual-scope-update', (_event, args: { sessionId: string; scope: unknown }) => {
getBackendClient().sendContextualScopeUpdate(args);
});
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
@@ -53,12 +137,33 @@ app.on('ready', () => {
initDb();
const win = createWindow();
createIPCHandler({ router: appRouter, windows: [win] });
// AI init is best-effort — never block window creation
initAI().catch((err) => console.error('[AI] Init failed:', err));
// Vector DB init + migration is best-effort — runs after window is shown
initVectorDb()
.then(() => migrateNotesIfNeeded())
.catch((err) => console.error('[VectorDB] Init or migration failed:', err));
// Persistent device WebSocket for agent triggers — best-effort on startup
getAuthManager()
.isAuthenticated()
.then((authenticated) => {
if (authenticated) {
void getBackendClient().connectPersistent();
// Best-effort notes backfill — runs after WS is likely connected
setTimeout(() => {
backfillNoteSummaries().catch((err) =>
console.error('[NotesBackfill] Startup backfill failed:', err),
);
}, 5_000);
}
})
.catch((err) => console.error('[DeviceWS] Startup connect failed:', err));
startBriefScheduler();
startScoutScheduler();
// Delay so WS connection is likely up before triggering rescans
setTimeout(() => { void runDailyRescan(); }, 10_000);
});
// Clean up the persistent WS and backup timers before the app exits
app.on('will-quit', () => {
stopBriefScheduler();
stopScoutScheduler();
getBackendClient().disconnectPersistent();
});
// Quit when all windows are closed, except on macOS. There, it's common

105
src/main/router/ai-chat.ts Normal file
View File

@@ -0,0 +1,105 @@
// adiuvAI/src/main/router/ai-chat.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
import { eq, desc, asc } from 'drizzle-orm';
import { getDb } from '../db';
import { aiChatSessions, aiChatMessages } from '../db/schema';
import type { TRPCContext } from '../ipc';
const t = initTRPC.context<TRPCContext>().create();
const router = t.router;
const publicProcedure = t.procedure;
const ChannelSchema = z.enum(['home', 'contextual']);
const RoleSchema = z.enum(['user', 'assistant', 'system']);
export const aiChatRouter = router({
listSessions: publicProcedure
.input(z.object({ channel: ChannelSchema }))
.query(({ input }) => {
return getDb()
.select()
.from(aiChatSessions)
.where(eq(aiChatSessions.channel, input.channel))
.orderBy(desc(aiChatSessions.updatedAt))
.all();
}),
getSession: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const db = getDb();
const session = db
.select()
.from(aiChatSessions)
.where(eq(aiChatSessions.id, input.id))
.get();
if (!session) return null;
const messages = db
.select()
.from(aiChatMessages)
.where(eq(aiChatMessages.sessionId, input.id))
.orderBy(asc(aiChatMessages.createdAt))
.all();
return { session, messages };
}),
createSession: publicProcedure
.input(z.object({
channel: ChannelSchema,
initialScope: z.string().optional(),
}))
.mutation(({ input }) => {
const db = getDb();
const id = crypto.randomUUID();
const now = Date.now();
db.insert(aiChatSessions).values({
id,
channel: input.channel,
title: null,
createdAt: now,
updatedAt: now,
lastScope: input.initialScope ?? null,
}).run();
return { id };
}),
appendMessage: publicProcedure
.input(z.object({
sessionId: z.string(),
role: RoleSchema,
content: z.string(),
toolCalls: z.string().optional(),
toolResults: z.string().optional(),
scope: z.string().optional(),
}))
.mutation(({ input }) => {
const db = getDb();
const id = crypto.randomUUID();
const now = Date.now();
db.insert(aiChatMessages).values({
id,
sessionId: input.sessionId,
role: input.role,
content: input.content,
toolCalls: input.toolCalls ?? null,
toolResults: input.toolResults ?? null,
scope: input.scope ?? null,
createdAt: now,
}).run();
db.update(aiChatSessions)
.set({ updatedAt: now, lastScope: input.scope ?? null })
.where(eq(aiChatSessions.id, input.sessionId))
.run();
return { id };
}),
deleteSession: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(({ input }) => {
const db = getDb();
db.delete(aiChatMessages).where(eq(aiChatMessages.sessionId, input.id)).run();
db.delete(aiChatSessions).where(eq(aiChatSessions.id, input.id)).run();
return { ok: true };
}),
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,128 @@
// adiuvAI/src/main/router/projectFolders.ts
import { TRPCError, initTRPC } from '@trpc/server';
import { z } from 'zod';
import { dialog } from 'electron';
import { eq } from 'drizzle-orm';
import { getDb } from '../db';
import { projects, projectFolderFiles } from '../db/schema';
import { startIndexSession, type IndexProgress } from '../files/indexer';
import { scanFolder } from '../files/scanner';
import { getBackendClient, QuotaError } from '../api/backend-client';
import type { TRPCContext } from '../ipc';
const t = initTRPC.context<TRPCContext>().create();
const router = t.router;
const publicProcedure = t.procedure;
// In-memory map of active sessions per projectId so we can cancel
const _active = new Map<string, { cancel: () => void; lastProgress: IndexProgress }>();
export const projectFoldersRouter = router({
chooseFolder: publicProcedure.mutation(async () => {
const result = await dialog.showOpenDialog({ properties: ['openDirectory'] });
if (result.canceled || result.filePaths.length === 0) return null;
return result.filePaths[0];
}),
link: publicProcedure
.input(z.object({ projectId: z.string(), folderPath: z.string() }))
.mutation(({ input }) => {
const db = getDb();
db.update(projects)
.set({ folderPath: input.folderPath, folderLastScanStatus: 'idle', folderTotalFiles: 0 })
.where(eq(projects.id, input.projectId))
.run();
return { ok: true };
}),
unlink: publicProcedure
.input(z.object({ projectId: z.string() }))
.mutation(({ input }) => {
const db = getDb();
db.delete(projectFolderFiles).where(eq(projectFolderFiles.projectId, input.projectId)).run();
db.update(projects)
.set({
folderPath: null,
folderLastScannedAt: null,
folderLastScanStatus: 'idle',
folderTotalFiles: 0,
})
.where(eq(projects.id, input.projectId))
.run();
return { ok: true };
}),
startScan: publicProcedure
.input(z.object({ projectId: z.string() }))
.mutation(async ({ input }) => {
const db = getDb();
const proj = db.select().from(projects).where(eq(projects.id, input.projectId)).get();
if (!proj?.folderPath) throw new Error('No folder linked');
if (proj.folderLastScanStatus === 'scanning') throw new Error('Scan already in progress');
// Pre-flight: walk folder to estimate indexable file count, then ask the
// backend whether the user's tier allows proceeding.
const delta = await scanFolder(input.projectId, proj.folderPath);
const estimated = delta.newFiles.length + delta.changedFiles.length + delta.unchangedCount;
try {
await getBackendClient().checkFolderQuota(estimated);
} catch (err) {
if (err instanceof QuotaError) {
// Encode reason + backend message so the renderer can produce a
// localised toast without an extra RPC call.
throw new TRPCError({
code: 'FORBIDDEN',
message: `QUOTA:${err.reason}:${err.message}`,
});
}
// Network / auth errors: propagate as-is so the renderer shows a
// generic error toast rather than silently swallowing the problem.
throw err;
}
const session = await startIndexSession(input.projectId, (p) => {
const entry = _active.get(input.projectId);
if (entry) entry.lastProgress = p;
if (
p.status === 'completed' ||
p.status === 'cancelled' ||
p.status === 'quota_exceeded' ||
p.status === 'error'
) {
_active.delete(input.projectId);
}
});
_active.set(input.projectId, {
cancel: session.cancel,
lastProgress: { sessionId: session.sessionId, processed: 0, total: 0, status: 'starting' },
});
return { sessionId: session.sessionId };
}),
cancelScan: publicProcedure
.input(z.object({ projectId: z.string() }))
.mutation(({ input }) => {
const entry = _active.get(input.projectId);
if (entry) entry.cancel();
return { ok: true };
}),
getStatus: publicProcedure
.input(z.object({ projectId: z.string() }))
.query(({ input }) => {
const entry = _active.get(input.projectId);
return entry?.lastProgress ?? null;
}),
listFiles: publicProcedure
.input(z.object({ projectId: z.string() }))
.query(({ input }) => {
return getDb()
.select()
.from(projectFolderFiles)
.where(eq(projectFolderFiles.projectId, input.projectId))
.orderBy(projectFolderFiles.relativePath)
.all();
}),
});

View File

@@ -0,0 +1,119 @@
/**
* Scout scheduler — checks locally-stored scout configs on a periodic
* interval and triggers BE-orchestrated runs when they are due.
*
* Follows the same pattern as the daily brief scheduler in orchestrator.ts:
* a single `setInterval` tick that checks all enabled scouts.
*/
import { getLocalScouts, saveLocalScout, getDeviceId } from '../store';
import { getBackendClient } from '../api/backend-client';
import { getDb } from '../db';
import { scoutRuns } from '../db/schema';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** How often the scheduler checks for due scouts (ms). */
const TICK_INTERVAL_MS = 60_000; // 60 seconds
/**
* Cron expression → minimum interval in ms.
* We use a simple mapping for the supported presets; unknown cron values
* are treated as manual-only.
*/
const CRON_INTERVAL_MS: Record<string, number> = {
'*/15 * * * *': 15 * 60 * 1000,
'0 * * * *': 60 * 60 * 1000,
'0 */6 * * *': 6 * 60 * 60 * 1000,
'0 0 * * *': 24 * 60 * 60 * 1000,
};
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let schedulerTimer: ReturnType<typeof setInterval> | null = null;
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function startScoutScheduler(): void {
if (schedulerTimer) return;
schedulerTimer = setInterval(() => {
void tickScoutScheduler();
}, TICK_INTERVAL_MS);
// Run once immediately on start
void tickScoutScheduler();
}
export function stopScoutScheduler(): void {
if (schedulerTimer) {
clearInterval(schedulerTimer);
schedulerTimer = null;
}
}
// ---------------------------------------------------------------------------
// Tick
// ---------------------------------------------------------------------------
async function tickScoutScheduler(): Promise<void> {
const scouts = getLocalScouts();
const now = Date.now();
for (const scout of scouts) {
if (!scout.enabled) continue;
// Manual-only scouts don't auto-trigger
const intervalMs = CRON_INTERVAL_MS[scout.scheduleCron];
if (!intervalMs) continue;
// Check if enough time has passed since lastRunAt
if (scout.lastRunAt && now - scout.lastRunAt < intervalMs) continue;
try {
const activeScouts = scouts.length;
console.log(
`[ScoutScheduler] Triggering scout "${scout.name}" (id=${scout.id}) with lastRunAt=${scout.lastRunAt} (${scout.lastRunAt ? new Date(scout.lastRunAt).toISOString() : 'null'})`,
);
const response = await getBackendClient().proxyPost<{ id: string }>(
'/api/v1/scouts/trigger',
{
directory: scout.directory,
deviceId: getDeviceId(),
agentId: scout.id,
whatToExtract: scout.dataTypes,
batchInterval: scout.scheduleCron,
agentConfig: scout.agentConfig ?? undefined,
activeAgents: activeScouts,
lastRunAt: scout.lastRunAt ?? undefined,
},
);
// Create the run row immediately so it appears in history even if
// the scout finds nothing to create/update.
if (response?.id) {
try {
await getDb().insert(scoutRuns).values({
id: response.id,
scoutId: scout.id,
status: 'running',
startedAt: now,
}).onConflictDoNothing();
} catch { /* ignore — row may already exist */ }
}
// Mark the run time so we don't re-trigger until the next interval
saveLocalScout({ ...scout, lastRunAt: now });
console.log(`[ScoutScheduler] Triggered scout "${scout.name}" (id=${scout.id}).`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[ScoutScheduler] Failed to trigger scout "${scout.name}": ${msg}`);
}
}
}

View File

@@ -0,0 +1,39 @@
import { getDb } from '../db';
import { scoutSuggestions } from '../db/schema';
/**
* Shape of the `proposal` object inside a `scout_proposal` WS frame,
* after toCamelCase has been applied to the incoming JSON.
*/
export interface IncomingScoutProposal {
id: string;
scoutId: string;
sourceType: string;
sourceMsgRef: string;
rawSubject?: string | null;
rawSnippet?: string | null;
category: 'unprocessed';
payload?: Record<string, unknown> | null;
}
/**
* Persist a scout_proposal into the local scout_suggestions table.
* Idempotent: a duplicate `id` is silently ignored via onConflictDoNothing.
*/
export async function handleScoutProposal(p: IncomingScoutProposal): Promise<void> {
await getDb()
.insert(scoutSuggestions)
.values({
id: p.id,
scoutId: p.scoutId,
sourceType: p.sourceType,
sourceMsgRef: p.sourceMsgRef,
category: p.category,
payload: p.payload ? JSON.stringify(p.payload) : null,
rawSubject: p.rawSubject ?? null,
rawSnippet: p.rawSnippet ?? null,
status: 'pending',
proposedAt: Date.now(),
})
.onConflictDoNothing();
}

View File

@@ -1,14 +1,60 @@
import Store from 'electron-store';
// ---------------------------------------------------------------------------
// Local scout config — stored entirely on the FE, never on the backend.
// ---------------------------------------------------------------------------
export interface LocalScoutConfig {
id: string;
name: string;
directory: string;
dataTypes: string[];
/** Structured extraction config produced by the Journey setup flow. */
agentConfig: Record<string, unknown> | null;
scheduleCron: string;
enabled: boolean;
lastRunAt: number | null;
}
// ---------------------------------------------------------------------------
// Format preferences — stored locally, never sent to LLM
// ---------------------------------------------------------------------------
export interface FormatPrefs {
timezone: string;
dateFormat: string; // 'dd/MM/yyyy' | 'MM/dd/yyyy' | 'yyyy-MM-dd'
timeFormat: '12h' | '24h';
}
// ---------------------------------------------------------------------------
// App settings (electron-store shape)
// ---------------------------------------------------------------------------
interface AppSettings {
sidebarCollapsed: boolean;
aiProvider: string;
encryptedTokens: Record<string, string>;
userName: string;
/** Base URL of the Adiuva backend API (e.g. 'http://localhost:8000'). */
/** Base URL of the AdiuvAI backend API (e.g. 'http://localhost:8000'). */
backendUrl: string;
/**
* Stable device identifier — UUID v4 generated once on first launch and
* persisted forever. Used to bind local agents to the machine they were
* configured on.
*/
deviceId: string;
/** Cached daily brief — regenerated once per day or when relevant data changes. */
dailyBriefCache: { content: string; date: string } | null;
/** Locally-managed scout configurations. */
localScouts: LocalScoutConfig[];
/** OS-detected display format preferences. */
formatPrefs: FormatPrefs | null;
/** UI language code (e.g. 'en', 'it', 'es', 'fr', 'de'). */
uiLanguage: string;
/** Timeline zoom level. */
timelineZoom: ZoomLevel;
}
export type ZoomLevel = 'day' | 'week' | 'month';
let _store: Store<AppSettings> | null = null;
export function getStore(): Store<AppSettings> {
@@ -16,12 +62,91 @@ export function getStore(): Store<AppSettings> {
_store = new Store<AppSettings>({
defaults: {
sidebarCollapsed: false,
aiProvider: 'copilot',
encryptedTokens: {},
userName: 'there',
backendUrl: 'http://localhost:8000',
deviceId: '',
dailyBriefCache: null,
localScouts: [],
formatPrefs: null,
uiLanguage: 'en',
timelineZoom: 'day',
},
});
}
return _store;
}
/**
* Returns the stable device ID, generating and persisting a new UUID v4 on
* first call. Subsequent calls always return the same value.
*/
export function getDeviceId(): string {
const store = getStore();
let id = store.get('deviceId');
if (!id) {
id = crypto.randomUUID();
store.set('deviceId', id);
}
return id;
}
// ---------------------------------------------------------------------------
// Local scout helpers
// ---------------------------------------------------------------------------
export function getLocalScouts(): LocalScoutConfig[] {
return getStore().get('localScouts');
}
export function getLocalScout(id: string): LocalScoutConfig | undefined {
return getLocalScouts().find((s) => s.id === id);
}
export function saveLocalScout(scout: LocalScoutConfig): void {
const scouts = getLocalScouts();
const idx = scouts.findIndex((s) => s.id === scout.id);
if (idx >= 0) {
scouts[idx] = scout;
} else {
scouts.push(scout);
}
getStore().set('localScouts', scouts);
}
export function deleteLocalScout(id: string): void {
const scouts = getLocalScouts().filter((s) => s.id !== id);
getStore().set('localScouts', scouts);
}
// ---------------------------------------------------------------------------
// Format preference helpers
// ---------------------------------------------------------------------------
export function getFormatPrefs(): FormatPrefs | null {
return getStore().get('formatPrefs', null);
}
export function setFormatPrefs(prefs: FormatPrefs): void {
getStore().set('formatPrefs', prefs);
}
// ---------------------------------------------------------------------------
// UI language helpers
// ---------------------------------------------------------------------------
export function getUiLanguage(): string {
return getStore().get('uiLanguage', 'en');
}
export function setUiLanguage(lang: string): void {
getStore().set('uiLanguage', lang);
}
export function getTimelineZoom(): ZoomLevel {
const v = getStore().get('timelineZoom', 'day');
return v === 'day' || v === 'week' || v === 'month' ? v : 'day';
}
export function setTimelineZoom(level: ZoomLevel): void {
getStore().set('timelineZoom', level);
}

View File

@@ -20,24 +20,47 @@ 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_end'; requestId: string; mutations?: unknown[] };
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);
};
},
/** Fire-and-forget scope update for the contextual sidebar. Added in M4.7. */
sendContextualScopeUpdate: (args: { sessionId: string; scope: unknown }): Promise<void> =>
ipcRenderer.invoke('ai:contextual-scope-update', args),
/** Subscribe to Gmail OAuth callback from the deep link handler. Returns an unsubscribe function. */
onScoutGmailOAuthCallback: (cb: (data: { code: string; state: string }) => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: { code: string; state: string }) => cb(data);
ipcRenderer.on('scout:gmailOAuthCallback', handler);
return () => {
ipcRenderer.removeListener('scout:gmailOAuthCallback', handler);
};
},
});
// ---------------------------------------------------------------------------
// Dialog — native file/folder picker
// ---------------------------------------------------------------------------
contextBridge.exposeInMainWorld('electronDialog', {
showOpenDialog: (options: Electron.OpenDialogOptions): Promise<Electron.OpenDialogReturnValue> =>
ipcRenderer.invoke('dialog:showOpenDialog', options),
});

View File

@@ -0,0 +1,131 @@
import {
CheckCircle2,
XCircle,
AlertCircle,
Loader2,
Clock,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { trpc } from '@/lib/trpc';
import { useFormatPrefs, formatTs, formatDuration } from '@/lib/date';
// ---------------------------------------------------------------------------
// Types inferred from router return
// ---------------------------------------------------------------------------
type ScoutRunSummary = {
id: string;
scoutId: string;
status: 'running' | 'completed' | 'failed' | 'partial';
startedAt: number;
completedAt: number | null | undefined;
actionCounts: { created: number; updated: number; deleted: number };
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function statusBadge(status: string) {
switch (status) {
case 'completed':
return (
<Badge variant="secondary" className="gap-1 text-emerald-600 dark:text-emerald-400 shrink-0">
<CheckCircle2 className="size-3" /> Done
</Badge>
);
case 'failed':
return (
<Badge variant="destructive" className="gap-1 shrink-0">
<XCircle className="size-3" /> Failed
</Badge>
);
case 'running':
return (
<Badge variant="outline" className="gap-1 shrink-0">
<Loader2 className="size-3 animate-spin" /> Running
</Badge>
);
case 'partial':
return (
<Badge variant="outline" className="gap-1 text-amber-600 shrink-0">
<AlertCircle className="size-3" /> Partial
</Badge>
);
default:
return <Badge variant="outline" className="shrink-0">{status}</Badge>;
}
}
// ---------------------------------------------------------------------------
// Per-run row
// ---------------------------------------------------------------------------
function RunRow({ run }: { run: ScoutRunSummary }) {
const prefs = useFormatPrefs();
const duration = formatDuration(run.startedAt, run.completedAt);
const totalActions = run.actionCounts.created + run.actionCounts.updated + run.actionCounts.deleted;
return (
<div className="rounded-lg border bg-muted/20 overflow-hidden">
<div className="flex items-center gap-3 px-3 py-2 text-xs">
{statusBadge(run.status)}
<span className="text-muted-foreground shrink-0">{formatTs(run.startedAt, prefs)}</span>
{duration && (
<span className="flex items-center gap-1 text-muted-foreground shrink-0">
<Clock className="size-3" />
{duration}
</span>
)}
<span className="text-muted-foreground shrink-0">
{totalActions} action{totalActions !== 1 ? 's' : ''}
</span>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// ScoutRunLog
// ---------------------------------------------------------------------------
export function ScoutRunLog({ agentId, expanded }: { agentId: string; expanded: boolean }) {
const runsQuery = trpc.scout.runs.useQuery(
{ agentId, limit: 10 },
{ enabled: expanded },
);
if (!expanded) return null;
return (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
Run History
</p>
{runsQuery.isPending && (
<div className="flex flex-col gap-2">
{[0, 1, 2].map(i => (
<Skeleton key={i} className="h-9 w-full rounded-lg" />
))}
</div>
)}
{!runsQuery.isPending && (runsQuery.data ?? []).length === 0 && (
<p className="text-xs text-muted-foreground">No runs yet.</p>
)}
{!runsQuery.isPending && (runsQuery.data ?? []).length > 0 && (
<div className="flex flex-col gap-2">
{(runsQuery.data as ScoutRunSummary[]).map(run => (
<RunRow key={run.id} run={run} />
))}
</div>
)}
</div>
);
}

View File

@@ -1,30 +1,35 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Sparkles, KeyRound, ArrowUp, ListTodo, TrendingUp, AlertCircle, Lightbulb, ChevronDown, ChevronUp, X } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Link } from '@tanstack/react-router';
import { LogIn, ListTodo, TrendingUp, AlertCircle, Lightbulb, ChevronDown, ChevronUp, X, Sparkles } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { trpc } from '@/lib/trpc';
import { useAIChat, type UIChatContext } from '@/hooks/useAIChat';
import { ChatInputBox, type ChatInputBoxHandle } from './ChatInputBox';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useTaskBriefing } from '@/context/TaskBriefingContext';
import { TaskBriefingOverlay } from '@/components/brief/TaskBriefingOverlay';
import { ScrollArea } from '@/components/ui/scroll-area';
import { GradualBlur } from '@/components/ui/gradual-blur';
import { ChatSurface, MessageContent, ChatMarkdown } from './ChatSurface';
/** Fluid font size for chat messages — scales with viewport width */
const CHAT_FONT = 'clamp(1.125rem, 1.4vw, 1.375rem)';
// const CHAT_FONT = '1rem';
const SUGGESTION_CHIPS = [
{ icon: ListTodo, label: "What's on my plate today?" },
{ icon: TrendingUp, label: 'Summarize this week' },
{ icon: AlertCircle, label: 'Any overdue tasks?' },
{ icon: Lightbulb, label: 'Suggest next actions' },
{ icon: ListTodo, labelKey: 'home.chipWhatsOnMyPlate' },
{ icon: TrendingUp, labelKey: 'home.chipSummarizeWeek' },
{ icon: AlertCircle, labelKey: 'home.chipOverdueTasks' },
{ icon: Lightbulb, labelKey: 'home.chipSuggestActions' },
] as const;
function getTimeGreeting(): string {
function getTimeGreeting(t: (key: string) => string): string {
const hour = new Date().getHours();
if (hour < 12) return 'Good morning,';
if (hour < 17) return 'Good afternoon,';
return 'Good evening,';
if (hour < 12) return t('home.goodMorning');
if (hour < 17) return t('home.goodAfternoon');
return t('home.goodEvening');
}
/* Entrance animation: staggered fade-up */
@@ -42,66 +47,205 @@ 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,
};
/** Cached AI response minHeight — survives component remounts (navigation). */
let aiMinHeightCache: number | null = null;
interface AIChatPanelProps {
onOpenSettings?: () => void;
isHomePage?: boolean;
actionsRef?: React.MutableRefObject<{ clear: () => void } | null>;
onHasMessagesChange?: (has: boolean) => void;
}
export function AIChatPanel({
onOpenSettings,
isHomePage,
actionsRef,
onHasMessagesChange,
}: AIChatPanelProps) {
const hasTokenQuery = trpc.ai.hasToken.useQuery();
const { t } = useTranslation();
const utils = trpc.useUtils();
const taskBriefing = useTaskBriefing();
const authStatusQuery = trpc.auth.status.useQuery();
// Home-specific queries
const userNameQuery = trpc.settings.getUserName.useQuery(undefined, { enabled: !!isHomePage });
const profile = authStatusQuery.data?.profile;
const dueTodayQuery = trpc.tasks.dueToday.useQuery(undefined, { enabled: !!isHomePage });
// ---------------------------------------------------------------------------
// Home chat SQLite persistence (M2.4)
// ---------------------------------------------------------------------------
const HOME_SESSION_KEY = 'chat.home.lastSessionId';
const [homeSessionId, setHomeSessionId] = useState<string | null>(() =>
typeof window !== 'undefined' ? window.localStorage.getItem(HOME_SESSION_KEY) : null,
);
const createSession = trpc.aiChat.createSession.useMutation();
const appendMessage = trpc.aiChat.appendMessage.useMutation();
useEffect(() => {
let cancelled = false;
(async () => {
if (!homeSessionId) {
const { id } = await createSession.mutateAsync({ channel: 'home' });
if (cancelled) return;
window.localStorage.setItem(HOME_SESSION_KEY, id);
setHomeSessionId(id);
} else {
// Verify the session still exists. If row is missing (e.g. user
// deleted the db file), recreate.
const res = await utils.aiChat.getSession.fetch({ id: homeSessionId });
if (cancelled) return;
if (!res) {
const { id } = await createSession.mutateAsync({ channel: 'home' });
if (cancelled) return;
window.localStorage.setItem(HOME_SESSION_KEY, id);
setHomeSessionId(id);
}
// Note: hydrating past messages into useAIChat's in-memory cache
// is deferred to a follow-up task. Current behavior matches
// the previous in-memory cache lifetime.
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [homeSessionId]);
const chatContext = useMemo<UIChatContext>(
() => ({ type: 'global' as const }),
[],
);
const {
messages,
input,
setInput,
isStreaming,
streamingContent,
handleSend: chatHandleSend,
clearMessages,
cacheKey,
} = useAIChat(chatContext);
// Daily brief state (home page only)
const [dailyBrief, setDailyBrief] = useState<string | null>(null);
const [briefLoading, setBriefLoading] = useState(false);
// Persist each new user/assistant message to aiChatMessages in SQLite.
const persistedCountRef = useRef(0);
useEffect(() => {
if (!homeSessionId) return;
// Reset cursor when session changes or messages are cleared (new chat).
if (persistedCountRef.current > messages.length) {
persistedCountRef.current = 0;
}
const fresh = messages.slice(persistedCountRef.current);
for (const m of fresh) {
appendMessage.mutate({
sessionId: homeSessionId,
role: m.role,
content: m.content,
});
}
persistedCountRef.current = messages.length;
// appendMessage is stable (useMutation ref), intentionally omitted from deps.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messages, homeSessionId]);
const hasMessages = messages.length > 0 || isStreaming;
// Notify parent when conversation active state changes
useEffect(() => {
onHasMessagesChange?.(hasMessages);
}, [hasMessages, onHasMessagesChange]);
// 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);
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
// --- Scroll-to-user-message + shrinking placeholder ---
// --- Scroll-to-user-message + AI response minHeight ---
const chatInputWrapperRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<ChatInputBoxHandle>(null);
const briefWrapper = useRef<HTMLDivElement | null>(null);
const lastUserMsgRef = useRef<HTMLDivElement | null>(null);
const [streamingEl, setStreamingEl] = useState<HTMLDivElement | null>(null);
const [placeholderHeight, setPlaceholderHeight] = useState<number | null>(null);
const initialPlaceholderRef = useRef(0);
const lastAiRef = useRef<HTMLDivElement | null>(null);
const [aiMinHeight, setAiMinHeight] = useState<number | null>(
() => aiMinHeightCache,
);
// Expose clear action to parent (AppShell) via ref — placed after setAiMinHeight is in scope
if (actionsRef) {
actionsRef.current = {
clear: () => {
inputRef.current?.clear();
clearMessages();
aiMinHeightCache = null;
setAiMinHeight(null);
// Create a new SQLite session for the next conversation.
createSession.mutateAsync({ channel: 'home' }).then(({ id }) => {
window.localStorage.setItem(HOME_SESSION_KEY, id);
setHomeSessionId(id);
}).catch(() => {
// Non-fatal: next message will still attempt session verification.
});
},
};
}
const pendingScrollRef = useRef(false);
const briefMutation = trpc.ai.dailyBrief.useMutation();
// Stable layout values — recomputed only on mount and window resize.
const stableLayout = useRef({ messageGap: 0 });
useEffect(() => {
function measureStable() {
const rootStyles = window.getComputedStyle(document.documentElement);
const spacingNumber = parseFloat(rootStyles.getPropertyValue('--spacing').trim()) || 0;
const rootFontSize = parseFloat(rootStyles.fontSize) || 16;
stableLayout.current = {
messageGap: spacingNumber * 8 * rootFontSize,
};
}
measureStable();
window.addEventListener('resize', measureStable);
return () => window.removeEventListener('resize', measureStable);
}, []);
// When the user message appears in the list, set the placeholder and scroll it to the top
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,
staleTime: Infinity,
refetchOnWindowFocus: false,
});
// When user sends a message: compute how much vertical space remains below the
// user bubble → that becomes the minHeight of the AI response div. Then scroll.
useEffect(() => {
if (!pendingScrollRef.current) return;
const lastMsg = messages[messages.length - 1];
if (!lastMsg || lastMsg.role !== 'user') return;
pendingScrollRef.current = false;
const ph = Math.round(window.innerHeight * 0.71);
initialPlaceholderRef.current = ph;
setPlaceholderHeight(ph);
// Double-rAF: wait for the placeholder div to actually paint before scrolling
const { messageGap } = stableLayout.current;
const vhw = window.innerHeight;
const briefHeight = briefWrapper.current?.getBoundingClientRect().height || 0;
const userMsgHeight = lastUserMsgRef.current?.getBoundingClientRect().height || 0;
const minH = Math.max(0, Math.round(vhw - (briefHeight + userMsgHeight + messageGap)));
aiMinHeightCache = minH;
setAiMinHeight(minH);
// Auto-scroll — the only auto-scroll point in the entire flow
requestAnimationFrame(() => {
requestAnimationFrame(() => {
lastUserMsgRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
@@ -109,140 +253,167 @@ export function AIChatPanel({
});
}, [messages]);
// Shrink placeholder in real-time as AI streaming content grows
// Sync query result into module cache so remounts get it instantly.
useEffect(() => {
if (!isStreaming || !streamingEl) return;
const MIN_PADDING = 80;
const observer = new ResizeObserver(() => {
const contentHeight = streamingEl.getBoundingClientRect().height;
setPlaceholderHeight(Math.max(MIN_PADDING, initialPlaceholderRef.current - contentHeight));
const cached = cachedBriefQuery.data;
if (!cached) return;
briefModule.content = cached;
setDailyBrief(cached);
}, [cachedBriefQuery.data]);
// Listen for background brief refreshes pushed by the main process.
useEffect(() => {
if (!isHomePage) return;
const unsubscribe = window.electronAI.onBriefUpdated((content) => {
briefModule.content = content;
briefModule.streamFired = true;
setDailyBrief(content);
void utils.ai.getBrief.invalidate();
});
observer.observe(streamingEl);
return () => observer.disconnect();
}, [isStreaming, streamingEl]);
// Auto-fire daily brief on home page
return () => {
unsubscribe?.();
};
}, [isHomePage, utils]);
// Stream a fresh brief when cache is empty (once per app session).
useEffect(() => {
if (!isHomePage || hasFiredBrief.current || hasTokenQuery.data !== true) return;
hasFiredBrief.current = true;
setBriefLoading(true);
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 = '';
const requestId = crypto.randomUUID();
const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => {
if (done) {
let unsubscribe: (() => void) | null = window.electronAI.onStreamEvent((event) => {
if (event.requestId !== requestId) return;
if (event.type === 'stream_text') {
briefContentRef.current += event.chunk;
briefModule.content = briefContentRef.current;
setDailyBrief(briefContentRef.current);
setBriefLoading(false);
unsubscribe();
return;
} 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();
}
briefContentRef.current += token;
setDailyBrief(briefContentRef.current);
});
briefMutation.mutate(undefined, {
onSuccess: (data) => {
if (data.error) {
unsubscribe();
setDailyBrief(null);
setBriefLoading(false);
}
},
briefMutation.mutate({ requestId }, {
onError: () => {
unsubscribe();
setDailyBrief(null);
setBriefLoading(false);
unsubscribe?.();
unsubscribe = null;
briefModule.streamFired = false; // allow retry on error
},
});
}, [isHomePage, hasTokenQuery.data]); // briefMutation excluded — only fire once
const handleSend = useCallback(() => {
if (briefLoading) return;
return () => {
unsubscribe?.();
unsubscribe = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isHomePage, authStatusQuery.data?.authenticated, cachedBriefQuery.isLoading, cachedBriefQuery.data]);
const handleSend = useCallback((message: string) => {
pendingScrollRef.current = true;
chatHandleSend();
}, [briefLoading, chatHandleSend]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const hasMessages = messages.length > 0 || isStreaming;
chatHandleSend(message);
}, [chatHandleSend]);
// Derived values for home page
const dueCount = dueTodayQuery.data?.length ?? 0;
const userName = userNameQuery.data ?? 'there';
// When briefing is open on home page, show inline briefing section
if (isHomePage && taskBriefing.isOpen) {
return (
<div className="absolute inset-0 z-0">
<AnimatePresence mode="wait">
<TaskBriefingOverlay key="briefing" />
</AnimatePresence>
</div>
);
}
return (
<div className="absolute inset-0 z-0 flex flex-col bg-background">
{/* Sticky brief toast — anchored at top when chatting */}
<AnimatePresence>
{isHomePage && hasMessages && dailyBrief && !briefDismissed && (
<motion.div
initial={{ y: -80, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -80, opacity: 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
className="sticky top-0 z-30 flex justify-center px-4 pt-3 pb-1"
>
<div className="w-full max-w-2xl rounded-xl border border-border/60 bg-background/80 backdrop-blur-xl shadow-[0_8px_30px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.4)] ring-1 ring-border/10">
{/* Toast header — always visible */}
<div className="flex items-center gap-2 px-4 py-2.5">
<Sparkles size={14} className="text-primary shrink-0" />
<span className="text-xs font-semibold tracking-wide text-foreground">Daily Brief</span>
<div className="flex-1" />
<button
onClick={() => setBriefExpanded((v) => !v)}
aria-label={briefExpanded ? 'Collapse brief' : 'Expand brief'}
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
>
{briefExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
<button
onClick={() => setBriefDismissed(true)}
aria-label="Dismiss brief"
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
>
<X size={14} />
</button>
</div>
{/* Collapsed: one-line preview */}
{!briefExpanded && (
<div className="px-4 pb-3 -mt-1">
<p className="text-xs text-muted-foreground truncate">
{dailyBrief.replace(/[#*_~`>-]/g, '').slice(0, 120)}...
</p>
</div>
)}
{/* Expanded: full brief content */}
<AnimatePresence>
{briefExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: [0.25, 0.1, 0.25, 1] }}
className="overflow-hidden"
>
<div className="px-4 pb-3 max-h-64 overflow-y-auto">
<ChatMarkdown content={dailyBrief} />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="absolute inset-0 z-0 flex flex-col">
{/* Scrollable messages area */}
<div className="relative flex-1 min-h-0">
{/* Brief toast — floats over the scroll area so messages scroll underneath */}
<AnimatePresence>
{isHomePage && hasMessages && dailyBrief && !briefDismissed && (
<motion.div
ref={briefWrapper}
initial={{ y: -80, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -80, opacity: 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
className="absolute top-0 left-0 right-0 z-30 flex justify-center px-4 pt-3 pb-1"
>
<div className="w-full max-w-2xl rounded-xl border border-border/60 bg-background/60 backdrop-blur-md shadow-[0_8px_30px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.4)] ring-1 ring-border/10">
{/* Toast header — always visible */}
<div className="flex items-center gap-2 px-4 py-2.5">
<Sparkles size={14} className="text-primary shrink-0" />
<span className="text-xs font-semibold tracking-wide text-foreground">{t('home.dailyBrief')}</span>
<div className="flex-1" />
<button
onClick={() => setBriefExpanded((v) => !v)}
aria-label={briefExpanded ? 'Collapse brief' : 'Expand brief'}
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
>
{briefExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
<button
onClick={() => setBriefDismissed(true)}
aria-label="Dismiss brief"
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
>
<X size={14} />
</button>
</div>
{/* Collapsed: one-line preview */}
{!briefExpanded && (
<div className="px-4 pb-3 -mt-1">
<p className="text-xs text-muted-foreground truncate">
{dailyBrief.replace(/[#*_~`>-]/g, '').slice(0, 120)}...
</p>
</div>
)}
{/* Expanded: full brief content */}
<AnimatePresence>
{briefExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: [0.25, 0.1, 0.25, 1] }}
className="overflow-hidden"
>
<div className="px-4 pb-3 max-h-64 overflow-y-auto">
<MessageContent content={dailyBrief} />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Gradual blur at the bottom of messages */}
{hasMessages && (
<GradualBlur
position="bottom"
strength={0.6}
height="4rem"
height="4.5rem"
divCount={10}
curve="ease-out"
opacity={0.8}
@@ -251,18 +422,17 @@ export function AIChatPanel({
)}
<ScrollArea
className="h-full"
viewportRef={messagesContainerRef}
scrollbarClassName={hasMessages ? 'z-30' : undefined}
viewportClassName={
isHomePage && !hasMessages
? '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-center'
? '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-start'
: '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-end'
}
>
{/* Home page initial state: greeting + brief */}
{isHomePage && !hasMessages && (
<motion.div
className="mx-auto w-full max-w-4xl px-8 pt-14 pb-8"
className="mx-auto w-full max-w-4xl px-8 pt-35 pb-8"
variants={stagger}
initial="hidden"
animate="show"
@@ -274,13 +444,13 @@ export function AIChatPanel({
className="font-light tracking-wide text-muted-foreground"
style={{ fontSize: 'clamp(1rem, 1.6vw, 1.25rem)' }}
>
{getTimeGreeting()}
{getTimeGreeting(t)}
</span>
<h1
className="font-bold leading-[1.05]"
style={{ fontSize: 'clamp(3.25rem, 5.5vw, 5.5rem)', letterSpacing: '-0.035em' }}
>
{userName}
{profile?.name}
<span className="text-primary ml-3 inline-block"></span>
</h1>
{dueCount > 0 && (
@@ -288,22 +458,21 @@ export function AIChatPanel({
className="text-muted-foreground mt-2"
style={{ fontSize: 'clamp(0.875rem, 1.2vw, 1.125rem)' }}
>
<span className="text-foreground font-medium">{dueCount}</span>
{' '}task{dueCount !== 1 ? 's' : ''} due today
{t('home.tasksDueToday', { count: dueCount })}
</p>
)}
</motion.div>
{/* Daily brief */}
<motion.div variants={fadeUp} className="max-w-3xl">
{hasTokenQuery.data === false ? (
{authStatusQuery.data?.authenticated === false ? (
<div className="flex flex-col items-start gap-3 py-2">
<KeyRound size={20} className="text-muted-foreground" />
<LogIn size={20} className="text-muted-foreground" />
<p className="text-muted-foreground" style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}>
Configure your AI provider in Settings to enable the daily brief.
{t('home.loginPrompt')}
</p>
<Button variant="outline" size="sm" onClick={onOpenSettings}>
Open Settings
<Button variant="outline" size="sm" asChild>
<Link to="/settings">{t('home.openSettings')}</Link>
</Button>
</div>
) : briefLoading && !dailyBrief ? (
@@ -313,123 +482,69 @@ export function AIChatPanel({
<Skeleton className="h-5 w-2/3" />
</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>
<MessageContent content={dailyBrief} />
) : null}
{authStatusQuery.data?.authenticated !== false && (
<button
type="button"
onClick={() => taskBriefing.open()}
className="mt-3 text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline transition-colors"
>
{t('home.openTaskBriefing')}
</button>
)}
</motion.div>
{/* Input + suggestion links */}
<motion.div variants={fadeUp} className="max-w-3xl">
<ChatInput
input={input}
isStreaming={isStreaming || briefLoading}
onInputChange={setInput}
onKeyDown={handleKeyDown}
onSend={handleSend}
/>
<div className="relative rounded-2xl bg-background/70 backdrop-blur-xl border border-border/50 shadow-lg ring-1 ring-border/20 transition-shadow focus-within:shadow-xl focus-within:border-ring/50">
<ChatInputBox
ref={inputRef}
cacheKey={cacheKey}
isStreaming={isStreaming}
onSend={handleSend}
placeholder={t('home.askAnything')}
autoFocus
/>
</div>
<div className="flex flex-col gap-0.5 mt-5">
{SUGGESTION_CHIPS.map((chip) => (
{SUGGESTION_CHIPS.map((chip) => {
const label = t(chip.labelKey);
return (
<button
key={chip.label}
key={chip.labelKey}
type="button"
className="group flex items-center gap-3 py-1.5 text-muted-foreground transition-all duration-200 hover:text-foreground hover:translate-x-1 cursor-pointer text-left"
style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}
onClick={() => setInput(chip.label)}
onClick={() => { inputRef.current?.setValue(label); inputRef.current?.focus(); }}
>
<chip.icon
size={16}
className="shrink-0 transition-colors duration-200 group-hover:text-primary"
/>
<span>{chip.label}</span>
<span>{label}</span>
</button>
))}
);
})}
</div>
</motion.div>
</div>
</motion.div>
)}
{/* Home page with messages: brief stays, then messages */}
{/* Home page with messages: brief stays, then messages via ChatSurface */}
{isHomePage && hasMessages && (
<div className="mx-auto w-full max-w-6xl px-6 pt-8 pb-32">
<div className="flex flex-col gap-4">
{/* Chat messages */}
{messages.map((msg, idx) => {
const isLastMsg = idx === messages.length - 1;
if (msg.role === 'user') {
return (
<div
key={msg.id}
ref={isLastMsg ? lastUserMsgRef : undefined}
className="flex justify-end"
>
<div className="ml-auto max-w-[75%] rounded-2xl bg-muted px-4 py-2">
<ChatMarkdown content={msg.content} fontSize={CHAT_FONT} />
</div>
</div>
);
}
if (msg.error) {
return (
<div key={msg.id} className="mr-auto max-w-[75%]">
<p style={{ fontSize: CHAT_FONT }} className="text-destructive whitespace-pre-wrap">
{msg.content}
</p>
</div>
);
}
return (
<div key={msg.id} className="mr-auto max-w-[75%]">
<div className="flex items-center gap-1.5 mb-1">
<Sparkles size={16} className="text-foreground" />
<span style={{ fontSize: CHAT_FONT }} className="font-semibold">Adiuva</span>
</div>
<div className="pl-[22px]">
<ChatMarkdown content={msg.content} fontSize={CHAT_FONT} />
</div>
</div>
);
})}
{/* Streaming AI response */}
{isStreaming && (
<div ref={setStreamingEl} className="mr-auto max-w-[75%]">
<div className="flex items-center gap-1.5 mb-1">
<Sparkles size={16} className="text-foreground" />
<span style={{ fontSize: CHAT_FONT }} className="font-semibold">Adiuva</span>
</div>
{streamingContent ? (
<div className="pl-[22px]">
<ChatMarkdown content={streamingContent} fontSize={CHAT_FONT} />
</div>
) : (
<div className="space-y-2 pl-[22px]">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-32" />
</div>
)}
</div>
)}
{/* Placeholder: fills viewport after user message, shrinks as AI responds */}
{placeholderHeight !== null && (
<div
aria-hidden
style={{
height: placeholderHeight,
transition: 'height 180ms ease-out',
flexShrink: 0,
}}
/>
)}
</div>
</div>
<ChatSurface
variant="home"
messages={messages}
streamingContent={streamingContent}
isStreaming={isStreaming}
onSend={handleSend}
cacheKey={cacheKey}
aiMinHeight={aiMinHeight}
lastUserMsgRef={lastUserMsgRef}
lastAiRef={lastAiRef}
/>
)}
{/* Non-home messages */}
@@ -438,15 +553,17 @@ export function AIChatPanel({
{/* Fixed input — pinned to the bottom, above the blur */}
{hasMessages && (
<div className="absolute bottom-0 left-0 right-0 z-30 px-6 pb-5 pt-4 pointer-events-none">
<div ref={chatInputWrapperRef} className="absolute bottom-0 left-0 right-0 z-30 px-6 pb-5 pt-4 pointer-events-none">
<div className="relative pointer-events-auto mx-auto max-w-3xl">
<ChatInput
input={input}
isStreaming={isStreaming || briefLoading}
onInputChange={setInput}
onKeyDown={handleKeyDown}
onSend={handleSend}
/>
<div className="relative rounded-2xl bg-background/70 backdrop-blur-xl border border-border/50 shadow-lg ring-1 ring-border/20 transition-shadow focus-within:shadow-xl focus-within:border-ring/50">
<ChatInputBox
ref={inputRef}
cacheKey={cacheKey}
isStreaming={isStreaming}
onSend={handleSend}
placeholder={t('home.askAnything')}
/>
</div>
</div>
</div>
)}
@@ -454,79 +571,6 @@ export function AIChatPanel({
);
}
/* ---------- ChatInput: Floating glass card ---------- */
interface ChatInputProps {
input: string;
isStreaming: boolean;
onInputChange: (value: string) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onSend: () => void;
}
function ChatInput({
input,
isStreaming,
onInputChange,
onKeyDown,
onSend,
}: ChatInputProps) {
return (
<div className="relative rounded-2xl bg-background/70 backdrop-blur-xl border border-border/50 shadow-lg ring-1 ring-border/20 transition-shadow focus-within:shadow-xl focus-within:border-ring/50">
<div className="flex items-center gap-2 px-4 py-2.5">
<textarea
value={input}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Ask me anything..."
aria-label="Chat message"
rows={1}
className="flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground outline-none max-h-[7.5rem] overflow-y-auto"
style={{ fieldSizing: 'content' } as React.CSSProperties}
/>
<button
onClick={onSend}
disabled={!input.trim() || isStreaming}
aria-label="Send message"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100"
>
<ArrowUp size={16} />
</button>
</div>
</div>
);
}
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
export function ChatMarkdown({ content, size = 'sm', fontSize }: { content: string; size?: 'sm' | 'lg'; fontSize?: string }) {
return (
<div
className={`prose dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${size === 'lg' ? 'prose-base' : 'prose-sm'}`}
style={fontSize ? { fontSize } : undefined}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
pre: ({ children }) => (
<pre className="bg-muted rounded-lg p-3 overflow-x-auto text-xs">
{children}
</pre>
),
code: ({ children, className }) => {
if (!className) {
return (
<code className="bg-muted rounded px-1.5 py-0.5 text-xs font-mono">
{children}
</code>
);
}
return <code className={className}>{children}</code>;
},
}}
>
{content}
</ReactMarkdown>
</div>
);
}
// Re-export shared rendering utilities for consumers that previously imported
// them directly from AIChatPanel.
export { ChatMarkdown } from './ChatSurface';

View File

@@ -0,0 +1,17 @@
export interface AdiuvaIconProps {
size?: number;
}
export function AdiuvaIcon({ size = 24 }: AdiuvaIconProps) {
return (
<img
src="/logo/logo-mark.svg"
width={size}
height={size}
alt=""
aria-hidden="true"
draggable={false}
className="adiuva-mark-img select-none pointer-events-none"
/>
);
}

View File

@@ -0,0 +1,17 @@
import { useContextualChat } from '@/context/ContextualChatContext';
import { AdiuvaIcon } from './AdiuvaIcon';
export function AdiuvaTriggerButton() {
const { toggle, open } = useContextualChat();
return (
<button
type="button"
onClick={toggle}
title="Ask adiuvAI"
aria-pressed={open}
className="adiuva-btn sm"
>
<AdiuvaIcon size={24} />
</button>
);
}

View File

@@ -0,0 +1,137 @@
import { useState, useRef, useEffect, forwardRef, useImperativeHandle, useCallback } from 'react';
import { ArrowUp } from 'lucide-react';
import { readInputDraft, writeInputDraft } from '@/hooks/useAIChat';
export interface ChatInputBoxHandle {
getValue: () => string;
setValue: (v: string) => void;
clear: () => void;
focus: () => void;
}
type ChatInputBoxVariant = 'panel' | 'comment';
interface ChatInputBoxProps {
cacheKey: string;
isStreaming: boolean;
onSend: (message: string) => void;
placeholder?: string;
autoFocus?: boolean;
variant?: ChatInputBoxVariant;
}
const VARIANT_STYLES = {
panel: {
container: 'flex items-center gap-2 px-4 py-2.5',
textarea: 'flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground outline-none max-h-[7.5rem] overflow-y-auto',
button: 'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100',
iconSize: 16,
},
comment: {
container: 'flex items-center gap-2 px-3 py-2',
textarea: 'flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground outline-none max-h-32 overflow-y-auto',
button: 'flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed',
iconSize: 14,
},
} as const;
export const ChatInputBox = forwardRef<ChatInputBoxHandle, ChatInputBoxProps>(
({ cacheKey, isStreaming, onSend, placeholder, autoFocus, variant = 'panel' }, ref) => {
const styles = VARIANT_STYLES[variant];
const [value, setValue] = useState(() => readInputDraft(cacheKey));
const textareaRef = useRef<HTMLTextAreaElement>(null);
const valueRef = useRef(value);
valueRef.current = value;
// Re-init when the cache key changes (context switches).
const prevKeyRef = useRef(cacheKey);
useEffect(() => {
if (prevKeyRef.current !== cacheKey) {
prevKeyRef.current = cacheKey;
setValue(readInputDraft(cacheKey));
}
}, [cacheKey]);
// Debounced draft persistence — fires 250 ms after the last keystroke.
useEffect(() => {
const id = setTimeout(() => writeInputDraft(cacheKey, value), 250);
return () => clearTimeout(id);
}, [cacheKey, value]);
// Flush on unmount so a fast close/reopen preserves the current draft.
useEffect(() => {
return () => writeInputDraft(cacheKey, valueRef.current);
}, [cacheKey]);
useImperativeHandle(ref, () => ({
getValue: () => valueRef.current,
setValue: (v: string) => {
setValue(v);
// Move caret to end + focus after React commits the new value.
requestAnimationFrame(() => {
const el = textareaRef.current;
if (el) {
el.focus();
el.setSelectionRange(v.length, v.length);
}
});
},
clear: () => setValue(''),
focus: () => textareaRef.current?.focus(),
}));
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Guard IME composition — prevents spurious submit during Italian dead-key
// input (e.g. ` + e → è) and CJK composition sequences.
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (isStreaming) return;
const v = valueRef.current.trim();
if (!v) return;
setValue('');
writeInputDraft(cacheKey, '');
onSend(v);
}
},
[isStreaming, onSend, cacheKey],
);
const handleClick = useCallback(() => {
if (isStreaming) return;
const v = valueRef.current.trim();
if (!v) return;
setValue('');
writeInputDraft(cacheKey, '');
onSend(v);
}, [isStreaming, onSend, cacheKey]);
return (
<div className={styles.container}>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
aria-label="Chat message"
rows={1}
autoFocus={autoFocus}
className={styles.textarea}
style={{ fieldSizing: 'content' } as React.CSSProperties}
/>
<button
type="button"
onClick={handleClick}
disabled={!value.trim() || isStreaming}
aria-label="Send message"
className={styles.button}
>
<ArrowUp size={styles.iconSize} />
</button>
</div>
);
},
);
ChatInputBox.displayName = 'ChatInputBox';

View File

@@ -0,0 +1,502 @@
/**
* ChatSurface — pure presentational chat surface.
*
* Contains:
* - Message list rendering (user bubbles, AI messages with Sparkles header,
* inline entity/chart block parsing, error styling)
* - Streaming content placeholder
* - Scroll management
* - ChatInputBox wrapper
*
* Also exports shared rendering utilities (ChatMarkdown, MessageContent,
* AIMessage) so AIChatPanel can import them here and avoid circular deps.
*/
import {
memo,
forwardRef,
useMemo,
useEffect,
useRef,
} from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Sparkles } from 'lucide-react';
import { motion } from 'framer-motion';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { ChatEntityBlock } from './blocks/ChatEntityBlock';
import { ChatChartBlock } from './blocks/ChatChartBlock';
import { ChatInputBox, type ChatInputBoxHandle } from './ChatInputBox';
import type { ChatMessage } from '@/hooks/useAIChat';
import type { EntityRefBlockData, ChartBlockData } from '../../../shared/api-types';
// ---------------------------------------------------------------------------
// Inline tag parsing (mirrors AIChatPanel)
// ---------------------------------------------------------------------------
const ENTITY_TAG_RE =
/<(?<entity>task|project|note|timeline|timelineEvent)>(?:\[(?<bracketIds>[^\]]+)\]|(?<plainIds>[^<]+))<\/\k<entity>>/;
const CHART_TAG_RE = /<chart>(?<chartJson>\{[\s\S]*?\})<\/chart>/;
const INLINE_TAG_RE = new RegExp(`${ENTITY_TAG_RE.source}|${CHART_TAG_RE.source}`);
type ContentSegment =
| { type: 'text'; content: string }
| { type: 'entity'; entity: EntityRefBlockData['entity']; ids: string[] }
| { type: 'chart'; data: ChartBlockData };
function parseInlineTags(content: string): ContentSegment[] {
const segments: ContentSegment[] = [];
let remaining = content;
while (remaining) {
const match = INLINE_TAG_RE.exec(remaining);
if (!match) {
segments.push({ type: 'text', content: remaining });
break;
}
const before = remaining.slice(0, match.index);
if (before) segments.push({ type: 'text', content: before });
const groups = match.groups ?? {};
if (groups.entity) {
const entity = groups.entity as EntityRefBlockData['entity'];
const rawIds = groups.bracketIds ?? groups.plainIds ?? '';
const ids = rawIds
.split(',')
.map((id) => id.trim())
.filter(Boolean);
segments.push({ type: 'entity', entity, ids });
} else if (groups.chartJson) {
try {
const chartData = JSON.parse(groups.chartJson) as ChartBlockData;
segments.push({ type: 'chart', data: chartData });
} catch {
segments.push({ type: 'text', content: match[0] });
}
}
const matchIndex = typeof match.index === 'number' ? match.index : 0;
remaining = remaining.slice(matchIndex + match[0].length);
}
return segments;
}
function hasInlineTags(content: string): boolean {
return INLINE_TAG_RE.test(content);
}
function mergeTimelineSegments(segments: ContentSegment[]): ContentSegment[] {
const allTimelineIds: string[] = [];
for (const seg of segments) {
if (seg.type === 'entity' && (seg.entity === 'timeline' || seg.entity === 'timelineEvent')) {
allTimelineIds.push(...seg.ids);
}
}
const uniqueTimelineIds = [...new Set(allTimelineIds)];
if (!uniqueTimelineIds.length) return segments;
const merged: ContentSegment[] = [];
let lastTimelineInsertIndex = 0;
for (const seg of segments) {
if (seg.type === 'entity' && (seg.entity === 'timeline' || seg.entity === 'timelineEvent')) {
lastTimelineInsertIndex = merged.length;
continue;
}
merged.push(seg);
}
merged.splice(lastTimelineInsertIndex, 0, {
type: 'entity',
entity: 'timeline',
ids: uniqueTimelineIds,
});
return merged.filter((seg) => !(seg.type === 'text' && !seg.content.trim()));
}
function mergeConsecutiveTaskSegments(segments: ContentSegment[]): ContentSegment[] {
const merged: ContentSegment[] = [];
for (let i = 0; i < segments.length; i += 1) {
const current = segments[i];
if (!current) continue;
if (!(current?.type === 'entity' && current.entity === 'task')) {
merged.push(current);
continue;
}
const groupedIds: string[] = [...current.ids];
let j = i + 1;
while (j < segments.length) {
const next = segments[j];
if (next?.type === 'text' && !next.content.trim()) {
j += 1;
continue;
}
if (next?.type === 'entity' && next.entity === 'task') {
groupedIds.push(...next.ids);
j += 1;
continue;
}
break;
}
merged.push({
type: 'entity',
entity: 'task',
ids: [...new Set(groupedIds)],
});
i = j - 1;
}
return merged;
}
// ---------------------------------------------------------------------------
// ChatMarkdown — lightweight markdown renderer with GFM + styled code blocks
// ---------------------------------------------------------------------------
const REMARK_PLUGINS: Parameters<typeof ReactMarkdown>[0]['remarkPlugins'] = [remarkGfm];
const MARKDOWN_COMPONENTS: Parameters<typeof ReactMarkdown>[0]['components'] = {
pre: ({ children }) => (
<pre className="bg-muted rounded-lg p-3 overflow-x-auto text-xs">{children}</pre>
),
code: ({ children, className }: { children?: React.ReactNode; className?: string }) => {
if (!className) {
return (
<code className="bg-muted rounded px-1.5 py-0.5 text-xs font-mono">{children}</code>
);
}
return <code className={className}>{children}</code>;
},
};
export function ChatMarkdown({
content,
size = 'sm',
fontSize,
}: {
content: string;
size?: 'sm' | 'lg';
fontSize?: string;
}) {
return (
<div
className={`prose dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${size === 'lg' ? 'prose-base' : 'prose-sm'}`}
style={fontSize ? { fontSize } : undefined}
>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
{content}
</ReactMarkdown>
</div>
);
}
// ---------------------------------------------------------------------------
// MessageContent — text with inline entity + chart blocks
// ---------------------------------------------------------------------------
const blockAnimation = {
initial: { opacity: 0, scale: 0.95 },
animate: { opacity: 1, scale: 1 },
transition: { type: 'spring' as const, stiffness: 400, damping: 30 },
};
export const MessageContent = memo(function MessageContent({
content,
fontSize,
}: {
content: string;
fontSize?: string;
}) {
const segments = useMemo(
() => mergeConsecutiveTaskSegments(mergeTimelineSegments(parseInlineTags(content))),
[content],
);
if (segments.length === 1 && segments[0]?.type === 'text') {
return <ChatMarkdown content={content} fontSize={fontSize} />;
}
if (segments.length === 0) return null;
return (
<div className="flex flex-col gap-3">
{segments.map((seg, i) => {
if (seg.type === 'text') {
return <ChatMarkdown key={i} content={seg.content} fontSize={fontSize} />;
}
if (seg.type === 'chart') {
return (
<motion.div key={i} {...blockAnimation}>
<ChatChartBlock data={seg.data} />
</motion.div>
);
}
return (
<motion.div key={i} {...blockAnimation}>
<ChatEntityBlock data={{ entity: seg.entity, ids: seg.ids }} />
</motion.div>
);
})}
</div>
);
});
// ---------------------------------------------------------------------------
// AIMessage — shared layout for completed + streaming AI turns
// ---------------------------------------------------------------------------
interface AIMessageProps {
content: string;
bottomPad?: boolean;
minHeight?: number | null;
skeleton?: boolean;
}
export const AIMessage = memo(
forwardRef<HTMLDivElement, AIMessageProps>(({ content, bottomPad, minHeight, skeleton }, ref) => (
<div
ref={ref}
className={`mr-auto ${hasInlineTags(content) ? 'w-full' : 'max-w-[75%]'}`}
style={minHeight ? { minHeight } : undefined}
>
<div className="flex items-end gap-2.5 mb-1">
<Sparkles size={24} className="text-foreground" />
<span className="text-xl font-semibold leading-none">
adiuv<span className="font-bold text-primary">AI</span>
</span>
</div>
{skeleton ? (
<div className="space-y-2 pl-[32px] pb-40">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-32" />
</div>
) : (
<div className={`pl-[32px] flex flex-col gap-3${bottomPad ? ' pb-40' : ''}`}>
<MessageContent content={content} />
</div>
)}
</div>
)),
);
AIMessage.displayName = 'AIMessage';
// ---------------------------------------------------------------------------
// ChatSurface props
// ---------------------------------------------------------------------------
export interface ChatSurfaceProps {
messages: ChatMessage[];
streamingContent: string;
isStreaming: boolean;
onSend: (text: string) => void;
cacheKey: string;
variant: 'home' | 'contextual';
/** Slot rendered just above the input area (e.g. suggestion chips). */
aboveInputSlot?: React.ReactNode;
/** Extra bottom padding for the message list (default 120px). */
bottomPadPx?: number;
/** Ref forwarded to the ChatInputBox for imperative control. */
inputRef?: React.Ref<ChatInputBoxHandle>;
/** minHeight applied to the last AI message (home page scroll behaviour). */
aiMinHeight?: number | null;
/** Ref set on the last user message div for scroll targeting. */
lastUserMsgRef?: React.RefObject<HTMLDivElement | null>;
/** Ref set on the last AI message div. */
lastAiRef?: React.RefObject<HTMLDivElement | null>;
/** Additional class names for the scroll area viewport. */
viewportClassName?: string;
/** Whether the scroll area has messages (controls scrollbar z-index). */
hasMessages?: boolean;
/** i18n placeholder for the input field. */
placeholder?: string;
/** Hint shown when messages are empty and not streaming. Used by the contextual variant. */
emptyStateCopy?: React.ReactNode;
}
// ---------------------------------------------------------------------------
// ChatSurface component
// ---------------------------------------------------------------------------
export const ChatSurface = memo(function ChatSurface({
messages,
streamingContent,
isStreaming,
onSend,
cacheKey,
variant,
aboveInputSlot,
inputRef,
aiMinHeight,
lastUserMsgRef,
lastAiRef,
viewportClassName,
hasMessages,
placeholder,
emptyStateCopy,
}: ChatSurfaceProps) {
// Internal scroll ref — used only in contextual variant where we don't have
// the parent-managed scroll refs from the home path.
const internalScrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (variant !== 'contextual') return;
internalScrollRef.current?.scrollTo({
top: internalScrollRef.current.scrollHeight,
behavior: 'smooth',
});
}, [messages.length, streamingContent, variant]);
if (variant === 'home') {
// Home variant: delegates scroll management entirely to the parent
// (AIChatPanel owns ScrollArea + scroll-to-user-message logic).
// Renders only the message list rows + fixed input footer.
return (
<>
{/* Message list — rendered inside parent's ScrollArea */}
<div className="mx-auto w-full max-w-6xl px-6 pt-8">
<div className="flex flex-col gap-4">
<div aria-hidden style={{ height: '4vw', flexShrink: 0 }} />
{messages.map((msg, idx) => {
const isLast = idx === messages.length - 1;
const isLastUser = isLast && msg.role === 'user';
const isLastAssistant = isLast && msg.role === 'assistant' && !isStreaming;
if (msg.role === 'user') {
return (
<div
key={msg.id}
ref={isLastUser ? lastUserMsgRef : undefined}
className="flex justify-end"
>
<div className="ml-auto max-w-[75%] rounded-2xl bg-muted px-4 py-2">
<ChatMarkdown content={msg.content} />
</div>
</div>
);
}
if (msg.error) {
return (
<div key={msg.id} className="mr-auto max-w-[75%]">
<p className="text-destructive whitespace-pre-wrap">{msg.content}</p>
</div>
);
}
return (
<AIMessage
key={msg.id}
ref={isLastAssistant ? lastAiRef : undefined}
content={msg.content}
bottomPad={isLastAssistant}
minHeight={isLastAssistant ? aiMinHeight : undefined}
/>
);
})}
{isStreaming && (
<AIMessage
ref={lastAiRef}
content={streamingContent}
bottomPad
minHeight={aiMinHeight}
skeleton={!streamingContent}
/>
)}
</div>
</div>
{/* Above-input slot (suggestion chips, etc.) rendered by parent */}
{aboveInputSlot}
</>
);
}
// ---------------------------------------------------------------------------
// Contextual variant — self-contained scroll + absolute-positioned input
// ---------------------------------------------------------------------------
return (
<div className="flex flex-col h-full relative">
<ScrollArea
className="h-full"
scrollbarClassName={hasMessages ? 'z-30' : undefined}
viewportClassName={viewportClassName}
>
<div
ref={internalScrollRef}
className="flex flex-col gap-4 px-4"
style={{ paddingBottom: 120, paddingTop: 64 }}
>
{messages.length === 0 && !isStreaming && variant === 'contextual' && emptyStateCopy && (
<div className="text-center text-xs text-muted-foreground py-12 px-6 leading-relaxed">
{emptyStateCopy}
</div>
)}
{messages.map((msg) => {
if (msg.role === 'user') {
return (
<div key={msg.id} className="flex justify-end">
<div className="ml-auto max-w-[75%] rounded-2xl bg-muted px-4 py-2">
<ChatMarkdown content={msg.content} />
</div>
</div>
);
}
if (msg.error) {
return (
<div key={msg.id} className="mr-auto max-w-[75%]">
<p className="text-destructive whitespace-pre-wrap">{msg.content}</p>
</div>
);
}
return <AIMessage key={msg.id} content={msg.content} />;
})}
{isStreaming && (
<AIMessage
content={streamingContent}
skeleton={!streamingContent}
/>
)}
</div>
</ScrollArea>
{aboveInputSlot}
{/* Absolute-positioned input with gradient fade */}
<div className="absolute inset-x-0 bottom-0 px-4 pb-3 pointer-events-none">
<div
className="h-16 -mx-4 -mt-16 pointer-events-none"
style={{
background:
'linear-gradient(to bottom, transparent 0%, color-mix(in srgb, var(--background) 90%, transparent) 60%, var(--background) 100%)',
}}
/>
<div className="pointer-events-auto relative rounded-2xl bg-background/70 backdrop-blur-xl border border-border/50 shadow-lg ring-1 ring-border/20 transition-shadow focus-within:shadow-xl focus-within:border-ring/50">
<ChatInputBox
ref={inputRef}
onSend={onSend}
isStreaming={isStreaming}
cacheKey={cacheKey}
placeholder={placeholder}
/>
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,85 @@
import { useMemo } from 'react';
import { SquarePen } from 'lucide-react';
import { useContextualChat, type ContextualScope } from '@/context/ContextualChatContext';
import { ChatSurface } from './ChatSurface';
function scopeLabel(scope: ContextualScope | null): string | null {
if (!scope) return null;
switch (scope.page) {
case 'timeline':
return 'Timeline';
case 'tasks':
return 'Tasks';
case 'projects-list':
return 'Projects';
case 'project':
return scope.entityName ? `Project · ${scope.entityName}` : 'Project';
case 'note':
return scope.entityName ? `Note · ${scope.entityName}` : 'Note';
default:
return null;
}
}
export function ContextualSidebar() {
const { messages, isStreaming, streamingContent, send, newChat, sessionId, scope } =
useContextualChat();
const label = scopeLabel(scope);
const emptyStateCopy = useMemo(() => {
if (!scope) return null;
switch (scope.page) {
case 'tasks':
return 'Ask anything about your tasks — or "create a task for…"';
case 'projects-list':
return 'Ask about your projects, or kick off a new one.';
case 'timeline':
return "Ask about milestones, what's coming up, or what's overdue.";
case 'project':
return scope.entityName
? `Ask anything about ${scope.entityName} — recap, tasks, status.`
: null;
case 'note':
return scope.entityName
? `Ask about ${scope.entityName}. (Note editing comes in a later release.)`
: null;
default:
return null;
}
}, [scope]);
return (
<div className="relative h-full w-full bg-transparent">
<div className="absolute top-[10px] left-[8px] z-10 flex items-center gap-2">
<button
type="button"
onClick={() => {
void newChat();
}}
aria-label="New conversation"
title="New chat"
className="flex h-6 w-6 items-center justify-center rounded-sm bg-background/60 text-muted-foreground backdrop-blur-md transition-colors hover:text-foreground hover:bg-accent"
>
<SquarePen size={14} />
</button>
{label && (
<div
className="inline-flex h-6 items-center rounded-sm bg-background/60 px-2 text-[11px] font-medium text-muted-foreground backdrop-blur-md"
title={`Current context: ${label}`}
>
{label}
</div>
)}
</div>
<ChatSurface
messages={messages}
streamingContent={streamingContent}
isStreaming={isStreaming}
onSend={send}
cacheKey={`contextual:${sessionId ?? 'none'}`}
variant="contextual"
emptyStateCopy={emptyStateCopy}
/>
</div>
);
}

View File

@@ -1,389 +0,0 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { useNavigate, useRouterState } from '@tanstack/react-router';
import { X, ArrowUp } from 'lucide-react';
import {
useFloatingChat,
computeDualAnchor,
getChatWidth,
CHAT_HEIGHT,
PADDING,
} from '@/context/FloatingChatContext';
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',
};
function FloatingChatInner() {
const { state, sections, close, setMorphTarget, moveToSection, updatePosition, setPendingSection } = useFloatingChat();
const utils = trpc.useUtils();
const navigate = useNavigate();
const routerState = useRouterState();
const prevPathRef = useRef(routerState.location.pathname);
// Active section lookup
const activeSection = sections.get(state.activeSectionId ?? '');
// Chat context derived from active section
const chatContext = useMemo<UIChatContext>(
() => ({
type: activeSection?.projectId ? 'project' : 'global',
projectId: activeSection?.projectId,
uiContext: activeSection?.label,
}),
[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;
}
// Cross-page: section not registered, navigate to its route
const route = SECTION_ROUTES[sectionId];
if (!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('/')) {
void navigate({ to: route });
}
// 'note' type requires noteId — skip cross-page for now
}, [sections, moveToSection, setPendingSection, state.projectId, navigate]);
const {
messages,
input,
setInput,
isStreaming,
streamingContent,
handleSend,
clearMessages,
} = useAIChat(chatContext, { onSectionTag: handleSectionTag });
const containerRef = useRef<HTMLDivElement>(null);
// ---- Close on Escape ----
useEffect(() => {
if (!state.isOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
close();
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [state.isOpen, close]);
// ---- Close on route change (unless cross-page navigation pending) ----
useEffect(() => {
const currentPath = routerState.location.pathname;
if (prevPathRef.current !== currentPath && state.isOpen && !state.pendingSection) {
close();
}
prevPathRef.current = currentPath;
}, [routerState.location.pathname, state.isOpen, state.pendingSection, close]);
// ---- Clear messages on close ----
const prevOpenRef = useRef(state.isOpen);
useEffect(() => {
if (prevOpenRef.current && !state.isOpen) {
clearMessages();
}
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(() => {
if (!state.isOpen) return;
const handler = () => {
// Re-anchor if the container would go offscreen
const el = containerRef.current;
if (el) {
const rect = el.getBoundingClientRect();
if (rect.right > window.innerWidth || rect.bottom > window.innerHeight) {
el.style.left = `${Math.max(PADDING, Math.min(state.position.x, window.innerWidth - getChatWidth() - PADDING))}px`;
el.style.top = `${Math.max(PADDING, Math.min(state.position.y, window.innerHeight - CHAT_HEIGHT - PADDING))}px`;
}
}
};
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, [state.isOpen, state.position.x, state.position.y]);
// ---- Scroll tracking: dual-anchor repositioning ----
useEffect(() => {
if (!state.isOpen || !state.activeSectionId) return;
const section = sections.get(state.activeSectionId);
if (!section || section.anchorMode === 'right-margin') return;
const el = section.ref.current;
if (!el) return;
// Find scrollable ancestor
let scrollParent: HTMLElement | null = el.parentElement;
while (scrollParent) {
const style = getComputedStyle(scrollParent);
if (style.overflow === 'auto' || style.overflow === 'scroll' ||
style.overflowY === 'auto' || style.overflowY === 'scroll') {
break;
}
// Also check for Radix ScrollArea viewport
if (scrollParent.hasAttribute('data-radix-scroll-area-viewport')) break;
scrollParent = scrollParent.parentElement;
}
if (!scrollParent) return;
let rafId: number | null = null;
const handleScroll = () => {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
const newPos = computeDualAnchor(section);
if (newPos) {
updatePosition(newPos);
}
// null = fully off-screen → freeze (do nothing)
});
};
scrollParent.addEventListener('scroll', handleScroll, { passive: true });
return () => {
scrollParent.removeEventListener('scroll', handleScroll);
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, [state.isOpen, state.activeSectionId, sections, updatePosition]);
// ---- Auto-scroll messages ----
const scrollRef = useRef<HTMLDivElement>(null);
const scrollToBottom = useCallback(() => {
const el = scrollRef.current;
if (el) el.scrollTo({ top: el.scrollHeight });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent, scrollToBottom]);
// ---- Auto-focus input on open ----
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (state.isOpen) {
const timer = setTimeout(() => inputRef.current?.focus(), 100);
return () => clearTimeout(timer);
}
}, [state.isOpen]);
// ---- Input handling ----
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const hasMessages = messages.length > 0 || isStreaming;
// Expand the messages panel upward if there's enough space above the input bar,
// otherwise expand downward. 320px = 300px max-h + 8px gap + 12px buffer.
const expandUp = state.position.y >= 320;
return (
<AnimatePresence>
{state.isOpen && (
<motion.div
ref={containerRef}
key="floating-chat"
layout
layoutId={state.morphTargetId ?? undefined}
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 12 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
style={{
position: 'fixed',
left: state.position.x,
top: state.position.y,
width: state.position.width,
zIndex: 9999,
}}
className="relative"
>
{/* ---- Messages panel — floats above or below the input bar ---- */}
<AnimatePresence>
{hasMessages && (
<motion.div
key="messages-panel"
initial={{ opacity: 0, scale: 0.97, y: expandUp ? 8 : -8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: expandUp ? 8 : -8 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
style={{
position: 'absolute',
width: '100%',
...(expandUp
? { bottom: 'calc(100% + 8px)' }
: { top: 'calc(100% + 8px)' }),
}}
className="rounded-2xl overflow-hidden"
>
<div
ref={scrollRef}
className="max-h-[300px] overflow-y-auto rounded-2xl [&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/40"
>
<div className="flex flex-col gap-2.5 p-3">
{messages.map((msg) => {
if (msg.role === 'user') {
return (
<div key={msg.id} className="flex justify-end">
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-br-md px-3.5 py-2">
<p className="text-xs whitespace-pre-wrap leading-relaxed text-foreground">
{msg.content}
</p>
</div>
</div>
);
}
if (msg.error) {
return (
<div key={msg.id} className="flex justify-start">
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2 !border-destructive/30">
<p className="text-xs text-destructive whitespace-pre-wrap leading-relaxed">
{msg.content}
</p>
</div>
</div>
);
}
return (
<div key={msg.id} className="flex justify-start">
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2">
<div className="text-xs text-foreground">
<ChatMarkdown content={msg.content} />
</div>
</div>
</div>
);
})}
{/* Streaming */}
{isStreaming && (
<div className="flex justify-start">
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2">
{streamingContent ? (
<div className="text-xs text-foreground">
<ChatMarkdown content={streamingContent} />
</div>
) : (
<div className="space-y-1.5 py-0.5">
<Skeleton className="h-3 w-36" />
<Skeleton className="h-3 w-24" />
</div>
)}
</div>
</div>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* ---- Floating input bar ---- */}
<div className="glass-surface relative rounded-2xl transition-shadow focus-within:shadow-[0_8px_60px_-8px_rgba(0,0,0,0.35)]">
{/* Close button */}
<button
onClick={close}
className="absolute -top-1.5 -right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-muted/90 backdrop-blur-sm border border-border/50 shadow-sm hover:bg-muted transition-colors z-10"
>
<X size={10} />
</button>
<div className="flex items-center gap-2 px-3 py-2.5">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Ask about ${activeSection?.label ?? 'this section'}...`}
rows={1}
className="flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground/60 outline-none max-h-20 overflow-y-auto"
style={{ fieldSizing: 'content' } as React.CSSProperties}
/>
<button
onClick={() => handleSend()}
disabled={!input.trim() || isStreaming}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed"
>
<ArrowUp size={14} />
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
export function FloatingChatPortal() {
return createPortal(<FloatingChatInner />, document.body);
}

View File

@@ -0,0 +1,184 @@
import { useMemo } from 'react';
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
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 } = blockData;
// config is optional — the AI sometimes omits it and embeds color in data items instead
const config = blockData.config ?? {};
const chartConfig = useMemo(() => {
const cfg: ChartConfig = {};
const entries = Object.entries(config);
for (let i = 0; i < entries.length; i++) {
const [key, val] = entries[i];
// Normalize: guard against missing colors and the legacy hsl(var(--chart-N)) pattern
// (chart vars are oklch values, so the hsl wrapper produces invalid CSS → black fills).
const raw = val.color ?? '';
const color =
raw && !/^hsl\(var\(/.test(raw) ? raw : `var(--chart-${(i % 5) + 1})`;
cfg[key] = { label: val.label, color };
}
return cfg;
}, [config]);
const dataKeys = useMemo(() => {
const keys = Object.keys(config);
if (keys.length > 0) return keys;
// Infer series keys from first data row when config is absent
const first = data[0];
if (!first) return ['value'];
return Object.entries(first)
.filter(([k, v]) => k !== 'name' && k !== 'color' && typeof v === 'number')
.map(([k]) => k);
}, [config, data]);
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%"
>
{data.map((_, i) => (
<Cell key={i} fill={`var(--chart-${(i % 5) + 1})`} />
))}
</Pie>
</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,267 @@
import { useState, useCallback, useMemo } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { FileText, FolderOpen, Sparkles } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { TaskRow } from '@/components/tasks/TaskRow';
import type { TaskItem } from '@/components/tasks/task-types';
import { TaskDetailSheet } from '@/components/tasks/TaskDetailSheet';
import { EditTaskDialog } from '@/components/tasks/EditTaskDialog';
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
import { ChatTimelineBlock } from './ChatTimelineBlock';
import { useFormatPrefs, formatDate } from '@/lib/date';
import type { EntityRefBlockData } from '../../../../../shared/api-types';
export function ChatEntityBlock({ data }: { data: EntityRefBlockData }) {
const { entity, ids } = data;
switch (entity) {
case 'task':
return <TaskEntityBlock ids={ids} />;
case 'project':
return <ProjectEntityBlock ids={ids} />;
case 'note':
return <NoteEntityBlock ids={ids} />;
case 'timeline':
return <TimelineEntityBlock ids={ids} />;
case 'timelineEvent':
return <TimelineEventEntityBlock ids={ids} />;
default:
return null;
}
}
// ---------------------------------------------------------------------------
// Tasks
// ---------------------------------------------------------------------------
function TaskEntityBlock({ ids }: { ids: string[] }) {
const utils = trpc.useUtils();
const { data: tasksList } = trpc.tasks.byIds.useQuery({ ids }, { enabled: ids.length > 0 });
const { notify, notifyError } = useNotify();
const updateTask = trpc.tasks.update.useMutation({
onSuccess: () => {
void utils.tasks.byIds.invalidate({ ids });
void utils.tasks.list.invalidate();
},
onError: (err) => notifyError('toast.task.updateError', err),
});
const deleteTask = trpc.tasks.delete.useMutation({
onSuccess: () => {
notify('warning', 'toast.task.deleted');
void utils.tasks.byIds.invalidate({ ids });
void utils.tasks.list.invalidate();
},
onError: (err) => notifyError('toast.task.deleteError', err),
});
const [viewTask, setViewTask] = useState<TaskItem | null>(null);
const [editTask, setEditTask] = useState<TaskItem | null>(null);
const handleToggle = useCallback(
(taskId: string, currentStatus: string | null) => {
const nextStatus =
currentStatus === 'todo' ? 'in_progress' :
currentStatus === 'in_progress' ? 'done' : 'todo';
updateTask.mutate({ id: taskId, status: nextStatus });
},
[updateTask],
);
if (!tasksList?.length) return null;
return (
<>
<EntityWrapper label="Tasks">
{tasksList.map((task) => (
<TaskRow
key={task.id}
task={task}
onToggle={handleToggle}
onClick={setViewTask}
/>
))}
</EntityWrapper>
<TaskDetailSheet
task={viewTask}
open={!!viewTask}
onOpenChange={(open) => { if (!open) setViewTask(null); }}
onEdit={(task) => { setViewTask(null); setEditTask(task); }}
onDelete={(id) => { deleteTask.mutate({ id }); setViewTask(null); }}
/>
<EditTaskDialog
task={editTask}
open={!!editTask}
onOpenChange={(open: boolean) => { if (!open) setEditTask(null); }}
/>
</>
);
}
// ---------------------------------------------------------------------------
// Projects
// ---------------------------------------------------------------------------
function ProjectEntityBlock({ ids }: { ids: string[] }) {
const navigate = useNavigate();
const { data: allProjects } = trpc.projects.list.useQuery();
const filtered = useMemo(
() => allProjects?.filter((p) => ids.includes(p.id)) ?? [],
[allProjects, ids],
);
if (!filtered.length) return null;
return (
<EntityWrapper label="Projects">
{filtered.map((p) => (
<Item
key={p.id}
variant="outline"
size="sm"
className="cursor-pointer hover:bg-accent/50"
onClick={() => void navigate({ to: '/projects', search: { projectId: p.id } })}
>
<ItemMedia variant="icon">
<FolderOpen className="h-4 w-4 text-muted-foreground" />
</ItemMedia>
<ItemContent>
<ItemTitle>{p.name}</ItemTitle>
</ItemContent>
</Item>
))}
</EntityWrapper>
);
}
// ---------------------------------------------------------------------------
// Notes
// ---------------------------------------------------------------------------
function NoteEntityBlock({ ids }: { ids: string[] }) {
const navigate = useNavigate();
const { data: allNotes } = trpc.notes.list.useQuery();
const filtered = useMemo(
() => allNotes?.filter((n) => ids.includes(n.id)) ?? [],
[allNotes, ids],
);
if (!filtered.length) return null;
return (
<EntityWrapper label="Notes">
{filtered.map((n) => (
<Item
key={n.id}
variant="outline"
size="sm"
className="cursor-pointer hover:bg-accent/50"
onClick={() => void navigate({ to: '/notes/$noteId', params: { noteId: n.id } })}
>
<ItemMedia variant="icon">
<FileText className="h-4 w-4 text-muted-foreground" />
</ItemMedia>
<ItemContent>
<ItemTitle>{n.title}</ItemTitle>
</ItemContent>
</Item>
))}
</EntityWrapper>
);
}
// ---------------------------------------------------------------------------
// Timeline Events
// ---------------------------------------------------------------------------
function TimelineEntityBlock({ ids }: { ids: string[] }) {
const { data: allEvents } = trpc.timelineEvents.list.useQuery();
const timelineData = useMemo(() => {
const filtered = allEvents?.filter((e) => ids.includes(e.id)) ?? [];
return {
events: filtered
.map((e) => {
const date = new Date(e.date).getTime();
const endDate = e.endDate ? new Date(e.endDate).getTime() : undefined;
return {
id: e.id,
title: e.title,
date,
endDate,
projectId: e.projectId,
isCompleted: e.isCompleted,
isAiSuggested: e.isAiSuggested,
};
})
.filter((e) => Number.isFinite(e.date)),
};
}, [allEvents, ids]);
if (!timelineData.events.length) return null;
return <ChatTimelineBlock data={timelineData} />;
}
function TimelineEventEntityBlock({ ids }: { ids: string[] }) {
const { data: allEvents } = trpc.timelineEvents.list.useQuery();
const prefs = useFormatPrefs();
const filtered = useMemo(
() => allEvents?.filter((e) => ids.includes(e.id)) ?? [],
[allEvents, ids],
);
if (!filtered.length) return null;
return (
<EntityWrapper label="Timeline Events">
{filtered.map((e) => (
<Item
key={e.id}
variant="outline"
size="sm"
>
<ItemMedia variant="icon">
{e.isAiSuggested ? (
<Sparkles className="h-4 w-4 text-amber-500" />
) : (
<div className="h-2 w-2 rounded-full bg-primary" />
)}
</ItemMedia>
<ItemContent>
<ItemTitle>{e.title}</ItemTitle>
{e.date && (
<ItemDescription>
{formatDate(e.date, prefs)}
</ItemDescription>
)}
</ItemContent>
</Item>
))}
</EntityWrapper>
);
}
// ---------------------------------------------------------------------------
// Shared wrapper
// ---------------------------------------------------------------------------
function EntityWrapper({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-2 rounded-lg bg-card p-3 w-full">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{label}
</p>
<div className="flex flex-col gap-1.5">
{children}
</div>
</div>
);
}

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,79 @@
import { useMemo } from 'react';
import { trpc } from '@/lib/trpc';
import { ProjectTimelineBox, type ProjectGroup } from '@/components/timeline/ProjectTimelineBox';
import type { TimelineEvent } from '@/components/timeline/ProjectTimeline';
import type { TimelineBlockData } from '../../../../../shared/api-types';
export function ChatTimelineBlock({ data }: { data: TimelineBlockData }) {
const { events: rawEvents } = data;
const { data: allProjects } = trpc.projects.list.useQuery({ includeArchived: true });
const events = useMemo<TimelineEvent[]>(() => {
return rawEvents
.map((event) => ({
id: event.id,
title: event.title,
date: event.date,
endDate: event.endDate ?? null,
type: ((event as { type?: string }).type ?? 'milestone') as TimelineEvent['type'],
projectId: event.projectId ?? null,
isCompleted: event.isCompleted ?? 0,
isAiSuggested: event.isAiSuggested ?? 0,
}))
.filter((event) => Number.isFinite(event.date));
}, [rawEvents]);
const groups = useMemo<ProjectGroup[]>(() => {
const PAD_MS = 3 * 24 * 60 * 60 * 1000;
const now = Date.now();
const byProject = new Map<string, TimelineEvent[]>();
for (const event of events) {
const key = event.projectId ?? '__unassigned__';
const current = byProject.get(key);
if (current) {
current.push(event);
} else {
byProject.set(key, [event]);
}
}
const builtGroups: ProjectGroup[] = [];
for (const [key, projectEvents] of byProject.entries()) {
const projectId = key === '__unassigned__' ? null : key;
const project = projectId
? allProjects?.find((p) => p.id === projectId)
: undefined;
const dates = projectEvents.flatMap((event) => (event.endDate ? [event.date, event.endDate] : [event.date]));
const minDate = Math.min(...dates, now);
const maxDate = Math.max(...dates, now);
builtGroups.push({
projectId,
projectName: project?.name ?? 'Timeline',
projectStatus: project?.status ?? 'active',
breadcrumb: [],
events: projectEvents,
startDate: new Date(minDate - PAD_MS),
endDate: new Date(maxDate + PAD_MS),
});
}
return builtGroups.sort((a, b) => a.projectName.localeCompare(b.projectName));
}, [events, allProjects]);
if (!events.length) return null;
return (
<div className="w-full flex flex-col gap-3">
{groups.map((group) => (
<ProjectTimelineBox
key={group.projectId ?? 'unassigned'}
group={group}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,12 @@
/**
* Block components are now rendered inline by the MessageContent parser
* in AIChatPanel.tsx. Import individual block components directly:
* - ChatEntityBlock
* - ChatChartBlock
* - ChatTableBlock
* - ChatTimelineBlock
*/
export { ChatEntityBlock } from './ChatEntityBlock';
export { ChatChartBlock } from './ChatChartBlock';
export { ChatTableBlock } from './ChatTableBlock';
export { ChatTimelineBlock } from './ChatTimelineBlock';

View File

@@ -0,0 +1,328 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
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';
// Google 'G' logo — inline SVG to avoid importing a second icon library.
function GoogleIcon({ className }: { className?: string }) {
return (
<svg className={className} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
);
}
// ---------------------------------------------------------------------------
// Sign-in form (login-03 layout)
// ---------------------------------------------------------------------------
function SignInForm({
className,
onSwitchMode,
...props
}: React.ComponentPropsWithoutRef<'div'> & { onSwitchMode: () => void }) {
const utils = trpc.useUtils();
const { t } = useTranslation();
const loginMutation = trpc.auth.login.useMutation();
const oauthMutation = trpc.auth.loginWithOAuth.useMutation();
const { notifyError } = useNotify();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const isBusy = loginMutation.isPending || oauthMutation.isPending;
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);
notifyError('toast.auth.loginError', err);
},
});
}
function handleGoogleLogin() {
setError('');
oauthMutation.mutate({ provider: 'google' }, {
onSuccess: (res) => {
if (!res.success) setError(res.error ?? 'Google sign-in failed');
else void utils.auth.status.invalidate();
},
onError: (err) => {
setError(err.message);
notifyError('toast.auth.oauthError', err);
},
});
}
return (
<div className={cn('flex flex-col gap-6', className)} {...props}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">{t('auth.welcomeBack')}</CardTitle>
<CardDescription>{t('auth.signInDescription')}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-6">
{/* Email + password form */}
<form onSubmit={handleSubmit}>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="email">{t('auth.email')}</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(''); }}
disabled={isBusy}
autoFocus
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">{t('auth.password')}</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => { setPassword(e.target.value); setError(''); }}
disabled={isBusy}
required
/>
</div>
{error && <p className="text-sm text-destructive -mt-1">{error}</p>}
<Button type="submit" className="w-full" disabled={isBusy || !email || !password}>
{loginMutation.isPending ? (
<><Loader2 className="mr-2 size-4 animate-spin" /> {t('auth.signingIn')}</>
) : t('auth.signIn')}
</Button>
</div>
</form>
{/* Divider */}
<div className="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border">
<span className="relative z-10 bg-card px-2 text-muted-foreground">
{t('auth.or')}
</span>
</div>
{/* Google OAuth button */}
<Button
type="button"
variant="outline"
className="w-full gap-2"
onClick={handleGoogleLogin}
disabled={isBusy}
>
{oauthMutation.isPending ? (
<><Loader2 className="size-4 animate-spin" /> {t('auth.waitingForBrowser')}</>
) : (
<><GoogleIcon className="size-4" /> {t('auth.signInWithGoogle')}</>
)}
</Button>
<div className="text-center text-sm">
{t('auth.noAccount')}{' '}
<button type="button" className="underline underline-offset-4 hover:text-primary" onClick={onSwitchMode} disabled={isBusy}>
{t('auth.signUp')}
</button>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
// ---------------------------------------------------------------------------
// Sign-up form (signup-03 layout)
// ---------------------------------------------------------------------------
function SignUpForm({
className,
onSwitchMode,
...props
}: React.ComponentPropsWithoutRef<'div'> & { onSwitchMode: () => void }) {
const utils = trpc.useUtils();
const { t } = useTranslation();
const registerMutation = trpc.auth.register.useMutation();
const { notifyError } = useNotify();
const [name, setName] = useState('');
const [surname, setSurname] = useState('');
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,
...(name && { name }),
...(surname && { surname }),
}, {
onSuccess: (res) => {
if (!res.success) setError(res.error ?? 'Registration failed');
else void utils.auth.status.invalidate();
},
onError: (err) => {
setError(err.message);
notifyError('toast.auth.registerError', err);
},
});
}
return (
<div className={cn('flex flex-col gap-6', className)} {...props}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">{t('auth.createAccount')}</CardTitle>
<CardDescription>{t('auth.createAccountDescription')}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<FieldGroup>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel htmlFor="reg-name">{t('auth.name')}</FieldLabel>
<Input
id="reg-name"
type="text"
placeholder="John"
value={name}
onChange={(e) => { setName(e.target.value); setError(''); }}
disabled={registerMutation.isPending}
autoFocus
/>
</Field>
<Field>
<FieldLabel htmlFor="reg-surname">{t('auth.surname')}</FieldLabel>
<Input
id="reg-surname"
type="text"
placeholder="Doe"
value={surname}
onChange={(e) => { setSurname(e.target.value); setError(''); }}
disabled={registerMutation.isPending}
/>
</Field>
</div>
<Field>
<FieldLabel htmlFor="reg-email">{t('auth.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">{t('auth.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}>
{t('auth.createAccountButton')}
</Button>
<FieldDescription className="text-center">
{t('auth.haveAccount')}{' '}
<button type="button" className="underline underline-offset-4 hover:text-primary" onClick={onSwitchMode}>
{t('auth.signInLink')}
</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 self-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 70" fill="none" width="120" height="47">
<style>{`
.compass-needle-login {
animation: compass-settle-login 5s ease-in-out infinite;
transform-origin: 32px 32px;
}
@keyframes compass-settle-login {
0% { transform: rotate(0deg); }
20% { transform: rotate(4deg); }
50% { transform: rotate(-3deg); }
80% { transform: rotate(2deg); }
100% { transform: rotate(0deg); }
}
`}</style>
<g transform="translate(2,2)">
<g className="compass-needle-login">
<path d="M32,4 L48,32 L16,32 Z" fill="#fbc881"/>
<path d="M16,32 L48,32 L32,60 Z" fill="currentColor"/>
<line x1="16" y1="32" x2="48" y2="32" stroke="currentColor" strokeWidth="0.5" opacity="0.12"/>
<circle cx="32" cy="32" r="2.5" fill="currentColor" opacity="0.18"/>
</g>
</g>
<text x="65" y="42" fontFamily="Geist, system-ui, -apple-system, sans-serif" fontSize="30" letterSpacing="-0.5">
<tspan fontWeight="400" fill="currentColor">adiuv</tspan><tspan fontWeight="700" fill="#fbc881">AI</tspan>
</text>
</svg>
</div>
{mode === 'login' ? (
<SignInForm onSwitchMode={() => setMode('register')} />
) : (
<SignUpForm onSwitchMode={() => setMode('login')} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { AlertCircle, Clock } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
interface BriefChatHeaderProps {
title: string;
projectName?: string | null;
priority: string;
dueDate?: number | null;
}
function relativeDate(ts: number, t: (key: string, opts?: Record<string, unknown>) => string): string {
const diff = ts - Date.now();
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
if (days < 0) return t('brief.overdue', { days: Math.abs(days) });
if (days === 0) return t('brief.dueToday');
if (days === 1) return t('brief.dueTomorrow');
return t('brief.dueInDays', { days });
}
const PRIORITY_STYLES: Record<string, string> = {
high: 'bg-destructive/15 text-destructive',
medium: 'bg-yellow-500/10 text-yellow-700 dark:text-yellow-400',
low: 'bg-muted text-muted-foreground',
};
export function BriefChatHeader({ title, projectName, priority, dueDate }: BriefChatHeaderProps) {
const { t } = useTranslation();
return (
<div className="px-5 pt-5 pb-4 space-y-2">
<h2 className="font-semibold text-base text-foreground leading-snug line-clamp-2">{title}</h2>
<div className="flex flex-wrap items-center gap-2">
{projectName && (
<span className="text-xs px-2 py-0.5 rounded-md bg-muted text-muted-foreground">
{projectName}
</span>
)}
<span
className={cn(
'text-xs px-2 py-0.5 rounded-md capitalize',
PRIORITY_STYLES[priority] ?? PRIORITY_STYLES.medium,
)}
>
{priority}
</span>
{dueDate != null && (
<span className={cn(
'flex items-center gap-1 text-xs',
dueDate < Date.now() ? 'text-destructive' : 'text-muted-foreground',
)}>
{dueDate < Date.now() ? <AlertCircle size={11} /> : <Clock size={11} />}
{relativeDate(dueDate, t)}
</span>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { Mail, FileText, MessageSquare, ScrollText } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { ScrollArea } from '@/components/ui/scroll-area';
interface CanvasPlaceholderProps {
content: string | null;
kind: string | null;
onContentChange?: (value: string) => void;
}
const KIND_META: Record<string, { label: string; Icon: React.ElementType }> = {
email: { label: 'Email Draft', Icon: Mail },
document: { label: 'Document Draft', Icon: FileText },
message: { label: 'Message Draft', Icon: MessageSquare },
};
export function CanvasPlaceholder({ content, kind }: CanvasPlaceholderProps) {
const { t } = useTranslation();
const meta = kind ? (KIND_META[kind] ?? { label: kind.charAt(0).toUpperCase() + kind.slice(1), Icon: ScrollText }) : null;
return (
<div className="flex flex-col h-full px-4 py-4 gap-3">
{/* Kind badge header */}
{meta && (
<div className="shrink-0 flex items-center gap-2 px-1">
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-primary/15 text-primary border border-primary/20">
<meta.Icon size={12} strokeWidth={2} />
<span className="text-xs font-medium tracking-tight">{meta.label}</span>
</div>
</div>
)}
{/* Paper surface */}
<div className="flex-1 min-h-0 rounded-2xl bg-background shadow-sm border border-border/30 overflow-hidden">
{content ? (
<ScrollArea className="h-full">
<div className="px-7 py-6">
<div className="prose prose-sm dark:prose-invert max-w-none text-foreground leading-relaxed
prose-p:text-foreground prose-p:leading-relaxed
prose-headings:text-foreground prose-headings:font-semibold
prose-strong:text-foreground prose-strong:font-semibold
prose-li:text-foreground
prose-a:text-primary prose-a:no-underline hover:prose-a:underline">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
</div>
</ScrollArea>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground/40 text-sm select-none">
{t('brief.canvas.empty')}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
interface CarouselControlsProps {
count: number;
activeIndex: number;
onPrev: () => void;
onNext: () => void;
}
export function CarouselControls({ count, activeIndex, onPrev, onNext }: CarouselControlsProps) {
return (
<div className="flex items-center justify-center gap-3 py-4">
<button
type="button"
onClick={onPrev}
disabled={activeIndex === 0}
aria-label="Previous task"
className="flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:text-foreground hover:bg-accent disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronLeft size={18} />
</button>
<div className="flex items-center gap-1.5">
{Array.from({ length: count }, (_, i) => (
<div
key={i}
className={cn(
'h-1.5 rounded-full transition-all duration-200',
i === activeIndex
? 'w-5 bg-foreground'
: 'w-1.5 bg-muted-foreground/40',
)}
/>
))}
</div>
<button
type="button"
onClick={onNext}
disabled={activeIndex === count - 1}
aria-label="Next task"
className="flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:text-foreground hover:bg-accent disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronRight size={18} />
</button>
</div>
);
}

View File

@@ -0,0 +1,343 @@
import { useState, useEffect, useRef, useCallback, memo } from 'react';
import { Sparkles } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { motion, AnimatePresence } from 'framer-motion';
import { trpc } from '@/lib/trpc';
import { ChatInputBox } from '@/components/ai/ChatInputBox';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useTranslation } from 'react-i18next';
interface ChatMessage {
id: string;
role: 'assistant' | 'user';
content: string;
error?: boolean;
}
interface BriefingResult {
briefingMarkdown: string;
canvasDraft: string | null;
canvasKind: string | null;
}
interface TaskBriefChatProps {
taskId: string;
projectId?: string | null;
/** Pre-loaded briefing from DB/session cache. Null triggers research. */
initialBriefing: BriefingResult | null;
onBriefingReady: (result: BriefingResult) => void;
}
export function TaskBriefChat({ taskId, projectId, initialBriefing, onBriefingReady }: TaskBriefChatProps) {
const { t } = useTranslation();
const sessionId = useRef(crypto.randomUUID()).current;
const cacheKey = `brief-${taskId}`;
// Load persisted follow-up messages — only when briefing already exists
const chatHistoryQuery = trpc.ai.getTaskBriefChats.useQuery(
{ taskId },
{ enabled: !!initialBriefing },
);
const saveChatMutation = trpc.ai.saveTaskBriefChat.useMutation();
const [messages, setMessages] = useState<ChatMessage[]>(() => {
if (initialBriefing) {
return [{ id: 'briefing', role: 'assistant', content: initialBriefing.briefingMarkdown }];
}
return [];
});
// True until DB history is applied (or skipped when no briefing)
const [historyLoaded, setHistoryLoaded] = useState(!initialBriefing);
const [isResearching, setIsResearching] = useState(!initialBriefing);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const [briefingText, setBriefingText] = useState<string>(initialBriefing?.briefingMarkdown ?? '');
const streamingRef = useRef('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const researchMutation = trpc.ai.taskBriefResearch.useMutation();
const chatMutation = trpc.ai.chat.useMutation();
// Merge DB history into messages once query settles
useEffect(() => {
if (historyLoaded) return;
if (chatHistoryQuery.isLoading) return;
setHistoryLoaded(true);
if (!chatHistoryQuery.data || chatHistoryQuery.data.length === 0) return;
setMessages([
{ id: 'briefing', role: 'assistant', content: initialBriefing!.briefingMarkdown },
...chatHistoryQuery.data.map((m) => ({
id: m.id,
role: m.role as 'user' | 'assistant',
content: m.content,
error: !!m.isError,
})),
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatHistoryQuery.isLoading, chatHistoryQuery.data]);
// Auto-scroll on new content
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingContent]);
// Research phase: fire on mount if no initial briefing
useEffect(() => {
if (initialBriefing) return;
const requestId = crypto.randomUUID();
let accumulated = '';
const unsubscribe = window.electronAI.onStreamEvent((event) => {
if (event.requestId !== requestId) return;
switch (event.type) {
case 'stream_text':
accumulated += event.chunk;
streamingRef.current = accumulated;
setStreamingContent(accumulated);
break;
case 'stream_end': {
const finalText = stripCanvas(streamingRef.current);
const mutations = event.mutations as Array<Record<string, unknown>> | undefined;
const canvasMut = mutations?.find((m) => m.type === 'canvas_draft');
const canvasDraft = (canvasMut?.content as string) ?? null;
const canvasKind = (canvasMut?.kind as string) ?? null;
setBriefingText(finalText);
setMessages([{ id: 'briefing', role: 'assistant', content: finalText }]);
setStreamingContent('');
streamingRef.current = '';
setIsResearching(false);
setHistoryLoaded(true);
onBriefingReady({ briefingMarkdown: finalText, canvasDraft, canvasKind });
unsubscribe();
break;
}
}
});
researchMutation.mutate({ taskId, requestId });
return () => unsubscribe();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSend = useCallback((message: string) => {
const trimmed = message.trim();
if (!trimmed || isStreaming || isResearching || !historyLoaded) return;
const userMsg: ChatMessage = { id: crypto.randomUUID(), role: 'user', content: trimmed };
setMessages((prev) => [...prev, userMsg]);
saveChatMutation.mutate({ id: userMsg.id, taskId, role: 'user', content: trimmed, createdAt: Date.now() });
setIsStreaming(true);
setStreamingContent('');
streamingRef.current = '';
const requestId = crypto.randomUUID();
const unsubscribe = window.electronAI.onStreamEvent((event) => {
if (event.requestId !== requestId) return;
switch (event.type) {
case 'stream_text':
streamingRef.current += event.chunk;
setStreamingContent(streamingRef.current);
break;
case 'stream_end': {
const finalContent = streamingRef.current;
const assistantMsgId = crypto.randomUUID();
setMessages((prev) => [...prev, { id: assistantMsgId, role: 'assistant', content: finalContent }]);
if (finalContent) {
saveChatMutation.mutate({ id: assistantMsgId, taskId, role: 'assistant', content: finalContent, createdAt: Date.now() });
}
setStreamingContent('');
streamingRef.current = '';
setIsStreaming(false);
unsubscribe();
break;
}
}
});
const conversationHistory = messages.slice(-20).map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.content,
}));
chatMutation.mutate(
{
requestId,
message: trimmed,
conversationHistory,
sessionId,
mode: 'contextual',
scope: { type: 'task', id: taskId },
briefMode: true,
briefingContext: briefingText || undefined,
},
{
onSuccess: (data) => {
if (data.error) {
unsubscribe();
setMessages((prev) => [...prev, {
id: crypto.randomUUID(), role: 'assistant', content: data.error!, error: true,
}]);
setStreamingContent('');
streamingRef.current = '';
setIsStreaming(false);
} else {
unsubscribe();
const content = streamingRef.current;
if (content) {
const assistantMsgId = crypto.randomUUID();
setMessages((prev) => [...prev, { id: assistantMsgId, role: 'assistant', content }]);
saveChatMutation.mutate({ id: assistantMsgId, taskId, role: 'assistant', content, createdAt: Date.now() });
}
setStreamingContent('');
streamingRef.current = '';
setIsStreaming(false);
}
},
onError: (err) => {
unsubscribe();
setMessages((prev) => [...prev, {
id: crypto.randomUUID(), role: 'assistant', content: err.message || 'An unexpected error occurred.', error: true,
}]);
setStreamingContent('');
streamingRef.current = '';
setIsStreaming(false);
},
},
);
}, [isStreaming, isResearching, historyLoaded, messages, taskId, sessionId, briefingText, chatMutation, saveChatMutation]);
const isInputBlocked = isStreaming || isResearching || !historyLoaded;
return (
<div className="flex flex-col h-full">
<ScrollArea className="flex-1 min-h-0">
<div className="px-5 py-5 space-y-5">
{/* Researching state */}
{isResearching && (
<div>
<div className="flex items-end gap-2.5 mb-1">
<Sparkles size={24} className="text-foreground" />
<span className="text-xl font-semibold leading-none">
adiuv<span className="font-bold text-primary">AI</span>
</span>
</div>
<div className="pl-[32px] space-y-2">
{streamingContent ? (
<BriefMarkdown content={stripCanvas(streamingContent, true)} />
) : (
<>
<p className="text-sm text-muted-foreground">{t('brief.researching')}</p>
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/3" />
</>
)}
</div>
</div>
)}
{/* Message list */}
<AnimatePresence initial={false}>
{messages.map((msg) => (
<motion.div
key={msg.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
>
{msg.role === 'assistant' ? (
<div>
<div className="flex items-end gap-2.5 mb-1">
<Sparkles size={24} className="text-foreground" />
<span className="text-xl font-semibold leading-none">
adiuv<span className="font-bold text-primary">AI</span>
</span>
</div>
<div className={`pl-[32px] ${msg.error ? 'text-destructive' : ''}`}>
<BriefMarkdown content={msg.content} />
</div>
</div>
) : (
<div className="flex justify-end">
<div className="max-w-[80%] rounded-2xl bg-muted px-4 py-2.5 text-sm">
{msg.content}
</div>
</div>
)}
</motion.div>
))}
</AnimatePresence>
{/* Streaming follow-up */}
{isStreaming && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
>
<div className="flex items-end gap-2.5 mb-1">
<Sparkles size={24} className="text-foreground" />
<span className="text-xl font-semibold leading-none">
adiuv<span className="font-bold text-primary">AI</span>
</span>
</div>
<div className="pl-[32px]">
{streamingContent ? (
<BriefMarkdown content={streamingContent} />
) : (
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-32" />
</div>
)}
</div>
</motion.div>
)}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
{/* Input */}
<div className="shrink-0 px-4 pb-4 pt-2">
<div className="relative rounded-2xl bg-background/70 backdrop-blur-xl border border-border/50 shadow-md ring-1 ring-border/20 transition-shadow focus-within:shadow-lg focus-within:border-ring/50">
<ChatInputBox
cacheKey={cacheKey}
isStreaming={isInputBlocked}
onSend={handleSend}
placeholder={t('brief.inputPlaceholder')}
/>
</div>
</div>
</div>
);
}
// Strip <canvas> block — canvas goes to right panel, not chat text
const CANVAS_COMPLETE_RE = /<canvas\b[^>]*>[\s\S]*?<\/canvas>/gi;
const CANVAS_PARTIAL_RE = /<canvas\b[\s\S]*$/i;
function stripCanvas(text: string, partial = false): string {
if (partial) return text.replace(CANVAS_PARTIAL_RE, '');
return text.replace(CANVAS_COMPLETE_RE, '').trim();
}
const BriefMarkdown = memo(function BriefMarkdown({ content }: { content: string }) {
return (
<div className="prose prose-sm dark:prose-invert max-w-none text-foreground text-sm">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);
});

View File

@@ -0,0 +1,20 @@
import { Link } from '@tanstack/react-router';
import { CheckCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
export function TaskBriefEmptyState() {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center justify-center h-full gap-4 text-center px-8">
<CheckCircle size={40} className="text-muted-foreground/50" />
<div className="space-y-1">
<p className="font-medium text-foreground">{t('brief.empty.title')}</p>
<p className="text-sm text-muted-foreground">{t('brief.empty.description')}</p>
</div>
<Button variant="outline" size="sm" asChild>
<Link to="/tasks">{t('brief.empty.cta')}</Link>
</Button>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { useEffect, useRef } from 'react';
import { ArrowLeft } from 'lucide-react';
import { motion } from 'framer-motion';
import { useTranslation } from 'react-i18next';
import { useBriefTasks } from '@/hooks/useBriefTasks';
import { useTaskBriefing } from '@/context/TaskBriefingContext';
import { TaskCarousel, clearCarouselBriefingCache } from './TaskCarousel';
import { TaskBriefEmptyState } from './TaskBriefEmptyState';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { Separator } from '@/components/ui/separator';
/**
* Inline task briefing section — renders inside the home page content area.
* No overlay/backdrop/fixed positioning; the parent hides/shows this via AnimatePresence.
*/
export function TaskBriefingOverlay() {
const { close, initialTaskId } = useTaskBriefing();
const { t } = useTranslation();
const { tasks, isLoading } = useBriefTasks();
const backBtnRef = useRef<HTMLButtonElement>(null);
// ESC to close
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') close();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [close]);
// Focus back button on mount
useEffect(() => {
backBtnRef.current?.focus();
}, []);
// Clear session cache when unmounted (i.e. closed)
useEffect(() => {
return () => clearCarouselBriefingCache();
}, []);
return (
<motion.div
className="flex flex-col h-full w-full"
initial={{ opacity: 0, x: 24 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 24 }}
transition={{ type: 'spring', stiffness: 400, damping: 38 }}
>
{/* Top bar — mirrors note page: SidebarTrigger | sep | back */}
<div className="flex h-14 shrink-0 items-center gap-1 px-3">
<SidebarTrigger />
<Separator orientation="vertical" className="data-[orientation=vertical]:h-4" />
<Button
ref={backBtnRef}
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={close}
aria-label={t('brief.controls.close')}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<span className="ml-auto text-xs text-muted-foreground/60">
{t('brief.overlayTitle')}
</span>
</div>
{/* Content */}
<div className="flex-1 min-h-0">
{isLoading ? (
<div className="p-8 space-y-3">
<Skeleton className="h-5 w-1/2" />
<Skeleton className="h-5 w-1/3" />
</div>
) : tasks.length === 0 ? (
<TaskBriefEmptyState />
) : (
<TaskCarousel tasks={tasks} initialTaskId={initialTaskId} />
)}
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,240 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { trpc } from '@/lib/trpc';
import { BriefChatHeader } from './BriefChatHeader';
import { TaskBriefChat } from './TaskBriefChat';
import { CanvasPlaceholder } from './CanvasPlaceholder';
import { CarouselControls } from './CarouselControls';
import { Skeleton } from '@/components/ui/skeleton';
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
interface BriefingResult {
briefingMarkdown: string;
canvasDraft: string | null;
canvasKind: string | null;
}
interface Task {
id: string;
title: string;
priority: string;
dueDate?: number | null;
projectId?: string | null;
projectName?: string | null;
}
interface TaskCarouselProps {
tasks: Task[];
initialTaskId?: string;
}
// Session-level briefing cache (survives carousel navigation, cleared on overlay close)
const briefingSessionCache = new Map<string, BriefingResult>();
export function clearCarouselBriefingCache() {
briefingSessionCache.clear();
}
const SLIDE_VARIANTS = {
enter: (dir: number) => ({ x: dir > 0 ? 60 : -60, opacity: 0 }),
center: { x: 0, opacity: 1 },
exit: (dir: number) => ({ x: dir > 0 ? -60 : 60, opacity: 0 }),
};
const SLIDE_TRANSITION = { type: 'spring' as const, stiffness: 400, damping: 40 };
export function TaskCarousel({ tasks, initialTaskId }: TaskCarouselProps) {
const initialIndex = initialTaskId
? Math.max(0, tasks.findIndex((t) => t.id === initialTaskId))
: 0;
const [activeIndex, setActiveIndex] = useState(initialIndex);
const [slideDir, setSlideDir] = useState(1);
// Per-task canvas drafts derived from briefings
const [canvasData, setCanvasData] = useState<Map<string, BriefingResult>>(new Map(briefingSessionCache));
const activeTask = tasks[activeIndex];
// Per-task DB briefing query
const dbBriefingQuery = trpc.ai.getTaskBriefing.useQuery(
{ taskId: activeTask?.id ?? '' },
{
enabled: !!activeTask && !briefingSessionCache.has(activeTask.id),
staleTime: Infinity,
},
);
// Resolve initial briefing from session cache → DB → null (triggers research)
const getCachedBriefing = (taskId: string): BriefingResult | null => {
if (briefingSessionCache.has(taskId)) return briefingSessionCache.get(taskId)!;
if (dbBriefingQuery.data && dbBriefingQuery.data.taskId === taskId) {
return {
briefingMarkdown: dbBriefingQuery.data.briefingMarkdown,
canvasDraft: dbBriefingQuery.data.canvasDraft ?? null,
canvasKind: dbBriefingQuery.data.canvasKind ?? null,
};
}
return null;
};
// Promote DB briefings to session cache so navigating back doesn't re-research
useEffect(() => {
if (!dbBriefingQuery.data || !activeTask) return;
if (dbBriefingQuery.data.taskId !== activeTask.id) return;
if (briefingSessionCache.has(activeTask.id)) return;
const result: BriefingResult = {
briefingMarkdown: dbBriefingQuery.data.briefingMarkdown,
canvasDraft: dbBriefingQuery.data.canvasDraft ?? null,
canvasKind: dbBriefingQuery.data.canvasKind ?? null,
};
briefingSessionCache.set(activeTask.id, result);
setCanvasData((prev) => new Map(prev).set(activeTask.id, result));
}, [dbBriefingQuery.data, activeTask]);
const handleBriefingReady = useCallback((result: BriefingResult) => {
if (!activeTask) return;
briefingSessionCache.set(activeTask.id, result);
setCanvasData((prev) => new Map(prev).set(activeTask.id, result));
}, [activeTask]);
const goTo = useCallback((index: number) => {
if (index < 0 || index >= tasks.length) return;
setSlideDir(index > activeIndex ? 1 : -1);
setActiveIndex(index);
}, [activeIndex, tasks.length]);
const lastWheelNavRef = useRef<number>(0);
const handleWheel = useCallback((e: React.WheelEvent) => {
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
const now = Date.now();
if (now - lastWheelNavRef.current < 600) return;
if (e.deltaX > 30) { lastWheelNavRef.current = now; goTo(activeIndex + 1); }
else if (e.deltaX < -30) { lastWheelNavRef.current = now; goTo(activeIndex - 1); }
}, [activeIndex, goTo]);
// Keyboard navigation
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') goTo(activeIndex - 1);
if (e.key === 'ArrowRight') goTo(activeIndex + 1);
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [activeIndex, goTo]);
// Prefetch next slide's briefing from DB so it's warm
const nextTaskId = tasks[activeIndex + 1]?.id;
trpc.ai.getTaskBriefing.useQuery(
{ taskId: nextTaskId ?? '' },
{ enabled: !!nextTaskId && !briefingSessionCache.has(nextTaskId), staleTime: Infinity },
);
if (!activeTask) return null;
// True while DB is still being checked — prevents TaskBriefChat mounting
// with undefined initialBriefing and firing unnecessary research
const isDbCheckPending = !briefingSessionCache.has(activeTask.id) && dbBriefingQuery.isFetching;
const initialBriefing = getCachedBriefing(activeTask.id);
const activeCanvas = canvasData.get(activeTask.id) ?? initialBriefing;
return (
<div className="flex flex-col h-full" onWheel={handleWheel}>
{/* Carousel slide area */}
<div className="flex-1 min-h-0 overflow-hidden">
<AnimatePresence initial={false} custom={slideDir} mode="wait">
<motion.div
key={activeTask.id}
custom={slideDir}
variants={SLIDE_VARIANTS}
initial="enter"
animate="center"
exit="exit"
transition={SLIDE_TRANSITION}
className="h-full w-full"
>
{activeCanvas?.canvasDraft ? (
<ResizablePanelGroup orientation="horizontal" className="h-full">
{/* Left: Chat panel */}
<ResizablePanel defaultSize={40} minSize={25}>
<div className="flex flex-col h-full">
<BriefChatHeader
title={activeTask.title}
projectName={activeTask.projectName}
priority={activeTask.priority}
dueDate={activeTask.dueDate}
/>
<div className="flex-1 min-h-0">
{isDbCheckPending ? (
<div className="px-5 py-5 space-y-3">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/3" />
</div>
) : (
<TaskBriefChat
key={activeTask.id}
taskId={activeTask.id}
projectId={activeTask.projectId}
initialBriefing={initialBriefing}
onBriefingReady={handleBriefingReady}
/>
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* Right: Canvas */}
<ResizablePanel defaultSize={60} minSize={30}>
<CanvasPlaceholder
content={activeCanvas.canvasDraft}
kind={activeCanvas.canvasKind ?? null}
/>
</ResizablePanel>
</ResizablePanelGroup>
) : (
/* No canvas: chat centered */
<div className="flex flex-col h-full w-full max-w-2xl mx-auto">
<BriefChatHeader
title={activeTask.title}
projectName={activeTask.projectName}
priority={activeTask.priority}
dueDate={activeTask.dueDate}
/>
<div className="flex-1 min-h-0">
{isDbCheckPending ? (
<div className="px-5 py-5 space-y-3">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/3" />
</div>
) : (
<TaskBriefChat
key={activeTask.id}
taskId={activeTask.id}
projectId={activeTask.projectId}
initialBriefing={initialBriefing}
onBriefingReady={handleBriefingReady}
/>
)}
</div>
</div>
)}
</motion.div>
</AnimatePresence>
</div>
{/* Bottom controls */}
<div className="shrink-0">
<CarouselControls
count={tasks.length}
activeIndex={activeIndex}
onPrev={() => goTo(activeIndex - 1)}
onNext={() => goTo(activeIndex + 1)}
/>
</div>
</div>
);
}

View File

@@ -1,83 +1,169 @@
import { useState } from 'react';
import { Link, useRouterState } from '@tanstack/react-router';
import { useMemo, useRef, useState } from 'react';
import { Link, useRouterState, useNavigate, useLocation } from '@tanstack/react-router';
import { LayoutGroup } from 'framer-motion';
import { ContextualChatProvider, useContextualChat } from '@/context/ContextualChatContext';
import { ContextualSidebar } from '@/components/ai/ContextualSidebar';
import { AdiuvaTriggerButton } from '@/components/ai/AdiuvaTriggerButton';
import { HeaderProvider, useHeader } from '@/context/HeaderContext';
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
import type { PanelSize } from 'react-resizable-panels';
import {
House,
ChartGantt,
ClipboardCheck,
FolderKanban,
PanelLeft,
Settings,
Sparkles,
Check,
LogOut,
Sun,
Moon,
Monitor,
Palette
ChevronsUpDown,
SquarePen,
Folder,
ChevronRight,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { trpc } from '@/lib/trpc';
import { useDoubleClickAI } from '@/hooks/useDoubleClickAI';
import { useNotify } from '@/hooks/useNotify';
import { useTheme } from '@/components/theme-provider';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarTrigger,
useSidebar,
} from '@/components/ui/sidebar';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuTrigger,
DropdownMenuSubContent,
DropdownMenuSubTrigger
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Separator } from '@/components/ui/separator';
import { AIChatPanel } from '@/components/ai/AIChatPanel';
import { FloatingChatPortal } from '@/components/ai/FloatingChat';
import { useTheme } from '@/components/theme-provider';
import { FloatingChatProvider } from '@/context/FloatingChatContext';
import { ExpandedClientsProvider, useExpandedClients } from '@/context/ExpandedClientsContext';
import { TaskBriefingProvider, useTaskBriefing } from '@/context/TaskBriefingContext';
import { LoginForm } from '@/components/auth/LoginForm';
import { OnboardingFlow } from '@/components/onboarding/OnboardingFlow';
import { useTranslation } from 'react-i18next';
const NAV_ITEMS = [
{ to: '/', icon: House, label: 'Home' },
{ to: '/timeline', icon: ChartGantt, label: 'Timeline' },
{ to: '/tasks', icon: ClipboardCheck, label: 'Tasks' },
{ to: '/projects', icon: FolderKanban, label: 'Projects' },
{ to: '/', icon: House, labelKey: 'nav.home' },
{ to: '/timeline', icon: ChartGantt, labelKey: 'nav.timeline' },
{ to: '/tasks', icon: ClipboardCheck, labelKey: 'nav.tasks' },
{ to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
] as const;
const SIDEBAR_SIZE_KEY = 'chat.sidebar.size';
const SIDEBAR_SIZE_MIN = 22;
const SIDEBAR_SIZE_MAX = 60;
const SIDEBAR_SIZE_DEFAULT = 38;
function readSidebarSize(): number {
if (typeof window === 'undefined') return SIDEBAR_SIZE_DEFAULT;
const v = window.localStorage.getItem(SIDEBAR_SIZE_KEY);
if (!v) return SIDEBAR_SIZE_DEFAULT;
const n = Number(v);
if (!Number.isFinite(n)) return SIDEBAR_SIZE_DEFAULT;
return Math.max(SIDEBAR_SIZE_MIN, Math.min(SIDEBAR_SIZE_MAX, n));
}
function MainArea({ children }: { children: React.ReactNode }) {
const loc = useLocation();
const isHome = loc.pathname === '/';
const { open } = useContextualChat();
// Read once per mount of the open state. When the user reopens the sidebar
// we want the most recent persisted size, so we key the PanelGroup on
// `open` so it remounts each open/close cycle.
const initialSize = useMemo(() => readSidebarSize(), [open]);
if (isHome || !open) {
return <>{children}</>;
}
return (
<ResizablePanelGroup
key={`sidebar-open-${initialSize}`}
orientation="horizontal"
className="h-full w-full"
>
<ResizablePanel defaultSize={`${100 - initialSize}%`} minSize="30%">
{children}
</ResizablePanel>
<ResizableHandle
withHandle
className="bg-border/40 hover:bg-border/70 transition-colors after:w-3! cursor-col-resize"
/>
<ResizablePanel
defaultSize={`${initialSize}%`}
minSize={`${SIDEBAR_SIZE_MIN}%`}
maxSize={`${SIDEBAR_SIZE_MAX}%`}
onResize={(panelSize: PanelSize) => {
const clamped = Math.max(
SIDEBAR_SIZE_MIN,
Math.min(SIDEBAR_SIZE_MAX, panelSize.asPercentage),
);
window.localStorage.setItem(SIDEBAR_SIZE_KEY, String(clamped));
}}
>
<div className="h-full w-full">
<ContextualSidebar />
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}
interface AppShellProps {
children: React.ReactNode;
}
export function AppShell({ children }: AppShellProps) {
return (
<FloatingChatProvider>
<AppShellInner>{children}</AppShellInner>
</FloatingChatProvider>
<ExpandedClientsProvider>
<TaskBriefingProvider>
<HeaderProvider>
<div className="flex w-full h-full">
<AppShellInner>{children}</AppShellInner>
</div>
</HeaderProvider>
</TaskBriefingProvider>
</ExpandedClientsProvider>
);
}
function AppShellInner({ children }: AppShellProps) {
useDoubleClickAI();
const { t } = useTranslation();
const authStatusQuery = trpc.auth.status.useQuery(undefined, {
staleTime: 5 * 60 * 1000,
retry: false,
});
const collapsedQuery = trpc.settings.getSidebarCollapsed.useQuery(undefined, {
staleTime: Infinity,
@@ -87,8 +173,6 @@ function AppShellInner({ children }: AppShellProps) {
const routerState = useRouterState();
const currentPath = routerState.location.pathname;
// Controlled open state (spec: "Controlled Sidebar" pattern)
// Default to collapsed (false) until the persisted preference loads
const [open, setOpen] = useState(() =>
collapsedQuery.data === undefined ? false : !collapsedQuery.data
);
@@ -98,106 +182,114 @@ function AppShellInner({ children }: AppShellProps) {
setSidebarCollapsedMutation.mutate({ collapsed: !value });
};
// AI token dialog state (shared between sidebar gear menu and AIChatPanel prompt)
const [tokenDialogOpen, setTokenDialogOpen] = useState(false);
const [tokenInput, setTokenInput] = useState('');
const [saved, setSaved] = useState(false);
const hasTokenQuery = trpc.ai.hasToken.useQuery();
const utils = trpc.useUtils();
const setTokenMutation = trpc.ai.setToken.useMutation({
onSuccess: () => {
setSaved(true);
setTokenInput('');
void utils.ai.hasToken.invalidate();
setTimeout(() => setSaved(false), 2000);
},
});
const taskBriefing = useTaskBriefing();
const chatActionsRef = useRef<{ clear: () => void } | null>(null);
const [homeChatHasMessages, setHomeChatHasMessages] = useState(false);
const isHomePage = currentPath === '/';
const isSettingsPage = currentPath.startsWith('/settings');
// Derive the page label from the current path for the breadcrumb
const matchedItem = NAV_ITEMS.find(
(item) => item.to !== '/' && currentPath.startsWith(item.to),
);
const routeLabel = matchedItem ? t(matchedItem.labelKey) : (currentPath.startsWith('/settings') ? t('nav.settings') : '');
// Dynamic label/extras published by child pages (e.g. ProjectDetail)
const { label: dynamicLabel, extras: headerExtras, leftExtras, rightExtras } = useHeader();
const pageLabel = dynamicLabel ?? routeLabel;
// All non-home, non-settings routes show the shared AppShell header.
// Projects and notes previously managed their own header; they now receive
// the shared header (with SidebarTrigger + AdiuvaTriggerButton) from here.
const showHeader = !isSettingsPage && !isHomePage;
if (authStatusQuery.data?.authenticated === false) {
return <LoginForm />;
}
if (
authStatusQuery.data?.profile &&
authStatusQuery.data.profile.onboardingCompletedAt == null
) {
return <OnboardingFlow profile={authStatusQuery.data.profile} />;
}
return (
<LayoutGroup>
<SidebarProvider open={open} onOpenChange={handleOpenChange}>
<SidebarProvider open={open} onOpenChange={handleOpenChange} className="h-full">
<AppSidebar
currentPath={currentPath}
setTokenDialogOpen={setTokenDialogOpen}
profile={authStatusQuery.data?.profile ?? null}
/>
<SidebarInset>
<SidebarInset className="min-w-0 min-h-0 overflow-x-hidden">
{isHomePage ? (
<AIChatPanel
onOpenSettings={() => setTokenDialogOpen(true)}
isHomePage
/>
) : (
<div className="relative flex flex-col h-full">
<header className="flex items-center gap-2 p-2 md:hidden">
<div className="relative flex-1 min-h-0">
{!taskBriefing.isOpen && (
<div className="absolute top-[10px] left-[8px] z-10 flex items-center gap-1 rounded-lg bg-background/60 backdrop-blur-md px-1 py-1">
<SidebarTrigger />
</header>
{children}
{homeChatHasMessages && (
<>
<Separator orientation="vertical" className="data-[orientation=vertical]:h-4 data-[orientation=vertical]:w-px mx-1" />
<button
onClick={() => chatActionsRef.current?.clear()}
aria-label="New conversation"
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-accent"
>
<SquarePen size={16} />
</button>
</>
)}
</div>
)}
<AIChatPanel isHomePage actionsRef={chatActionsRef} onHasMessagesChange={setHomeChatHasMessages} />
</div>
) : (
<ContextualChatProvider>
{/* MainArea wraps EVERYTHING (header + content) so the contextual
sidebar, when open, spans the full SidebarInset height. The
left ResizablePanel contains the header + scrollable body;
the right panel is the sidebar.
The inner overflow-hidden div scopes sticky elements (e.g.
ProjectTabBar) below the header without sliding behind it. */}
<MainArea>
<div className="flex flex-col h-full min-w-0">
{showHeader && (
<header className="flex h-14 shrink-0 items-center gap-2">
<div className="flex flex-1 items-center gap-2 px-3">
<SidebarTrigger />
<Separator orientation="vertical" className={`data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:h-4${leftExtras ? '' : ' mr-2'}`} />
{leftExtras ?? (
<h4 className="text-sm font-medium text-foreground">{pageLabel}</h4>
)}
{headerExtras}
<div className="flex-1" />
{rightExtras}
<AdiuvaTriggerButton />
</div>
</header>
)}
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
{children}
</div>
</div>
</MainArea>
</ContextualChatProvider>
)}
</SidebarInset>
</SidebarProvider>
{/* Floating AI Chat — portal to document.body */}
<FloatingChatPortal />
{/* AI Token Dialog — rendered outside Sidebar to avoid layout conflicts */}
<Dialog open={tokenDialogOpen} onOpenChange={(open) => {
setTokenDialogOpen(open);
if (!open) { setTokenInput(''); setSaved(false); }
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>AI Provider</DialogTitle>
<DialogDescription>
Configure your AI provider credentials for chat, summaries, and suggestions.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label className="text-sm font-medium">GitHub Copilot Token</label>
<Input
type="password"
placeholder="Paste your token here"
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Your token is stored securely in the OS keychain.
{hasTokenQuery.data === true && (
<span className="text-green-600 dark:text-green-400 ml-1">A token is currently stored.</span>
)}
</p>
</div>
<DialogFooter>
{saved && (
<span className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400 mr-auto">
<Check size={14} />
Saved
</span>
)}
<Button
disabled={!tokenInput.trim() || setTokenMutation.isPending}
onClick={() => setTokenMutation.mutate({ token: tokenInput.trim() })}
>
{setTokenMutation.isPending ? 'Saving...' : 'Save Token'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</LayoutGroup>
);
}
interface AppSidebarProps {
currentPath: string;
setTokenDialogOpen: (open: boolean) => void;
profile: { email: string; name?: string | null; surname?: string | null; tier: string; avatarUrl?: string | null } | null;
}
function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
const { toggleSidebar } = useSidebar();
const { theme, setTheme } = useTheme();
function AppSidebar({ currentPath, profile }: AppSidebarProps) {
const { t } = useTranslation();
return (
<Sidebar collapsible="icon">
{/* Logo */}
@@ -207,21 +299,15 @@ function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
<SidebarMenuButton size="lg" asChild>
<div className="cursor-default">
<div className="size-7 rounded-lg bg-primary flex items-center justify-center shrink-0">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
className="text-primary-foreground"
>
<path
d="M12 2L13.5 8.5L20 10L13.5 11.5L12 18L10.5 11.5L4 10L10.5 8.5L12 2Z"
fill="currentColor"
/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" width="18" height="18">
<path d="M32,4 L48,32 L16,32 Z" fill="#040404" opacity="0.85"/>
<path d="M16,32 L48,32 L32,60 Z" fill="#040404"/>
<line x1="16" y1="32" x2="48" y2="32" stroke="#040404" strokeWidth="0.5" opacity="0.12"/>
<circle cx="32" cy="32" r="2.5" fill="#040404" opacity="0.18"/>
</svg>
</div>
<span className="font-semibold text-sm text-foreground">
Adiuva
adiuv<span className="font-bold text-primary">AI</span>
</span>
</div>
</SidebarMenuButton>
@@ -234,11 +320,12 @@ function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{NAV_ITEMS.map(({ to, icon: Icon, label }) => {
{NAV_ITEMS.map(({ to, icon: Icon, labelKey }) => {
const isActive =
to === '/'
? currentPath === '/'
: currentPath.startsWith(to);
const label = t(labelKey);
return (
<SidebarMenuItem key={to}>
@@ -258,61 +345,323 @@ function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<NavProjects />
</SidebarContent>
{/* Settings gear + Collapse toggle */}
{/* User avatar + dropdown */}
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton tooltip="Settings">
<Settings />
<span>Settings</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="end" className="w-56">
<DropdownMenuItem onSelect={() => setTokenDialogOpen(true)}>
<Sparkles className="mr-2 size-4" />
AI Provider
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Palette className="mr-2 size-4" />
<span>Theme</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem onSelect={() => setTheme('light')}>
<Sun className="mr-2 size-4" />
Light
{theme === 'light' && <Check className="ml-auto size-4" />}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setTheme('dark')}>
<Moon className="mr-2 size-4" />
Dark
{theme === 'dark' && <Check className="ml-auto size-4" />}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setTheme('system')}>
<Monitor className="mr-2 size-4" />
System
{theme === 'system' && <Check className="ml-auto size-4" />}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton onClick={toggleSidebar} tooltip="Toggle Sidebar">
<PanelLeft />
<span>Collapse</span>
</SidebarMenuButton>
<NavUser profile={profile} currentPath={currentPath} />
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
);
}
// ---------------------------------------------------------------------------
// NavProjects — clients + projects tree in the sidebar
// ---------------------------------------------------------------------------
const NO_CLIENT_KEY = '__no_client__';
function NavProjects() {
const { state } = useSidebar();
const { t } = useTranslation();
const navigate = useNavigate();
const routerState = useRouterState();
const currentPath = routerState.location.pathname;
const currentProjectId = useMemo(() => {
const params = new URLSearchParams(routerState.location.search);
return params.get('projectId') ?? undefined;
}, [routerState.location.search]);
const { expandedClients, toggleClient, expandClients } = useExpandedClients();
const { data: projectList = [] } = trpc.projects.list.useQuery({ includeArchived: false });
const { data: clientList = [] } = trpc.clients.list.useQuery();
const topLevelClients = useMemo(() => clientList.filter((c) => !c.parentId), [clientList]);
const subClientsByParent = useMemo(() => {
const m = new Map<string, typeof clientList>();
for (const c of clientList) {
if (c.parentId) {
const arr = m.get(c.parentId);
if (arr) arr.push(c);
else m.set(c.parentId, [c]);
}
}
return m;
}, [clientList]);
const projectsByClient = useMemo(() => {
const m = new Map<string, typeof projectList>();
for (const p of projectList) {
const key = p.clientId ?? NO_CLIENT_KEY;
const arr = m.get(key);
if (arr) arr.push(p);
else m.set(key, [p]);
}
return m;
}, [projectList]);
function handleSelectProject(projectId: string) {
void navigate({ to: '/projects', search: { projectId } });
}
if (state === 'collapsed') return null;
if (currentPath.startsWith('/projects')) return null;
if (projectList.length === 0 && clientList.length === 0) return null;
const isProjectsActive = currentPath.startsWith('/projects');
const unassignedProjects = projectsByClient.get(NO_CLIENT_KEY) ?? [];
return (
<>
<SidebarGroup>
<SidebarGroupLabel>{t('projects.projects')}</SidebarGroupLabel>
<SidebarMenu>
{topLevelClients.map((client) => {
const isExpanded = expandedClients.has(client.id);
const directProjects = projectsByClient.get(client.id) ?? [];
const subClients = subClientsByParent.get(client.id) ?? [];
const hasChildren = directProjects.length > 0 || subClients.length > 0;
return (
<Collapsible
key={client.id}
open={isExpanded}
onOpenChange={() => toggleClient(client.id)}
asChild
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={client.name}>
<Folder />
<span>{client.name}</span>
{hasChildren && (
<ChevronRight
className={cn(
'ml-auto transition-transform duration-200',
isExpanded && 'rotate-90',
)}
/>
)}
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{subClients.map((subClient) => {
const subIsExpanded = expandedClients.has(subClient.id);
const subProjects = projectsByClient.get(subClient.id) ?? [];
return (
<Collapsible
key={subClient.id}
open={subIsExpanded}
onOpenChange={() => toggleClient(subClient.id)}
asChild
>
<SidebarMenuSubItem>
<CollapsibleTrigger asChild>
<SidebarMenuSubButton>
<Folder />
<span>{subClient.name}</span>
{subProjects.length > 0 && (
<ChevronRight
className={cn(
'ml-auto size-3 transition-transform duration-200',
subIsExpanded && 'rotate-90',
)}
/>
)}
</SidebarMenuSubButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{subProjects.map((p) => (
<SidebarMenuSubItem key={p.id}>
<SidebarMenuSubButton
isActive={isProjectsActive && currentProjectId === p.id}
onClick={() => handleSelectProject(p.id)}
>
<span>{p.name}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuSubItem>
</Collapsible>
);
})}
{directProjects.map((p) => (
<SidebarMenuSubItem key={p.id}>
<SidebarMenuSubButton
isActive={isProjectsActive && currentProjectId === p.id}
onClick={() => handleSelectProject(p.id)}
>
<span>{p.name}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
);
})}
{unassignedProjects.map((p) => (
<SidebarMenuItem key={p.id}>
<SidebarMenuButton
isActive={isProjectsActive && currentProjectId === p.id}
onClick={() => handleSelectProject(p.id)}
tooltip={p.name}
>
<span>{p.name}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
</>
);
}
// ---------------------------------------------------------------------------
// NavUser — avatar with dropdown (inspired by shadcn sidebar-07)
// ---------------------------------------------------------------------------
function NavUser({
profile,
currentPath,
}: {
profile: { email: string; name?: string | null; surname?: string | null; tier: string; avatarUrl?: string | null } | null;
currentPath: string;
}) {
const { isMobile } = useSidebar();
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
const logoutMutation = trpc.auth.logout.useMutation();
const { notify } = useNotify();
const utils = trpc.useUtils();
const email = profile?.email ?? 'User';
const displayName = [profile?.name, profile?.surname].filter(Boolean).join(' ') || email?.split('@')[0];
const initials = profile?.name && profile?.surname
? `${profile.name[0]}${profile.surname[0]}`.toUpperCase()
: (email?.split('@')[0] ?? 'US').slice(0, 2).toUpperCase();
function handleLogout() {
logoutMutation.mutate(undefined, {
onSuccess: () => {
notify('info', 'toast.auth.loggedOut');
void utils.auth.status.invalidate();
},
});
}
const themeOptions = [
{ value: 'light' as const, label: 'Light', icon: Sun },
{ value: 'dark' as const, label: 'Dark', icon: Moon },
{ value: 'system' as const, label: 'System', icon: Monitor },
];
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="size-8 rounded-lg">
{profile?.avatarUrl && <AvatarImage src={profile.avatarUrl} alt={displayName} />}
<AvatarFallback className="rounded-lg text-xs">
{initials}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{displayName}</span>
<span className="truncate text-xs text-muted-foreground">
{email}
</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="size-8 rounded-lg">
{profile?.avatarUrl && <AvatarImage src={profile.avatarUrl} alt={displayName} />}
<AvatarFallback className="rounded-lg text-xs">
{initials}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">
{displayName}
</span>
<span className="truncate text-xs text-muted-foreground">
{email}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link to="/settings">
<Settings className="mr-2 size-4" />
{t('nav.settings')}
</Link>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{theme === 'dark' ? (
<Moon className="mr-2 size-4" />
) : theme === 'light' ? (
<Sun className="mr-2 size-4" />
) : (
<Monitor className="mr-2 size-4" />
)}
Theme
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{themeOptions.map(({ value, label, icon: Icon }) => (
<DropdownMenuItem
key={value}
onClick={() => setTheme(value)}
>
<Icon className="mr-2 size-4" />
{label}
{theme === value && (
<span className="ml-auto text-xs text-muted-foreground">
Active
</span>
)}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} disabled={logoutMutation.isPending}>
<LogOut className="mr-2 size-4" />
{t('settings.signOut')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,82 @@
import { Sparkles, Check, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { NoteEdit } from '../../../shared/api-types';
interface PendingEditBlockProps {
edit: NoteEdit;
onApprove: () => void;
onReject: () => void;
isPending?: boolean;
}
const EDIT_TYPE_LABEL: Record<NoteEdit['type'], string> = {
append: 'Add at end',
insert: 'Insert',
replace: 'Replace',
};
export function PendingEditBlock({ edit, onApprove, onReject, isPending }: PendingEditBlockProps) {
return (
<div
className={cn(
'rounded-lg border border-dashed border-muted-foreground/40 bg-muted/30 p-4',
'flex flex-col gap-3',
)}
>
{/* Header */}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Sparkles className="h-3.5 w-3.5 shrink-0 text-primary" />
<span className="font-medium uppercase tracking-wide">
AI suggestion {EDIT_TYPE_LABEL[edit.type]}
</span>
</div>
{/* Reasoning */}
{edit.reasoning && (
<p className="text-xs text-muted-foreground italic">{edit.reasoning}</p>
)}
{/* Proposed content preview */}
<pre className="whitespace-pre-wrap rounded-md bg-background/60 p-3 text-sm leading-relaxed text-foreground font-sans">
{edit.proposedContent}
</pre>
{/* Anchor hint for insert/replace */}
{edit.type === 'replace' && edit.anchorText && (
<p className="text-xs text-muted-foreground">
Replaces: <span className="font-mono">{edit.anchorText.slice(0, 80)}</span>
</p>
)}
{edit.type === 'insert' && edit.anchorBefore && (
<p className="text-xs text-muted-foreground">
After: <span className="font-mono">{edit.anchorBefore.slice(0, 80)}</span>
</p>
)}
{/* Actions */}
<div className="flex items-center gap-2">
<Button
size="sm"
variant="default"
className="h-7 gap-1.5 text-xs"
onClick={onApprove}
disabled={isPending}
>
<Check className="h-3.5 w-3.5" />
Approve
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 gap-1.5 text-xs text-muted-foreground hover:text-destructive"
onClick={onReject}
disabled={isPending}
>
<X className="h-3.5 w-3.5" />
Reject
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,723 @@
import { useState, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronRight, ChevronLeft, Pencil, Check, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { JOB_ROLES, INDUSTRIES, USE_CASES, TONES } from './onboardingOptions';
import type { UserProfile } from '../../../shared/api-types';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type Step =
| 'welcome'
| 'jobRole'
| 'industry'
| 'useCase'
| 'tone'
| 'language'
| 'reviewing'
| 'done';
const STEP_ORDER: Step[] = [
'welcome',
'jobRole',
'industry',
'useCase',
'tone',
'language',
'reviewing',
];
interface OnboardingFlowProps {
profile: UserProfile;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const spring = { type: 'spring' as const, stiffness: 400, damping: 32 };
function AIBubble({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={spring}
className="flex items-start gap-3"
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" width="16" height="16">
<path d="M32,4 L48,32 L16,32 Z" fill="#040404" opacity="0.85"/>
<path d="M16,32 L48,32 L32,60 Z" fill="#040404"/>
<line x1="16" y1="32" x2="48" y2="32" stroke="#040404" strokeWidth="0.5" opacity="0.12"/>
<circle cx="32" cy="32" r="2.5" fill="#040404" opacity="0.18"/>
</svg>
</div>
<div className="rounded-2xl bg-muted/60 backdrop-blur-md border border-border/30 px-5 py-3.5 max-w-[85%]">
<div className="text-sm leading-relaxed">{children}</div>
</div>
</motion.div>
);
}
function UserBubble({ text }: { text: string }) {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={spring}
className="flex justify-end"
>
<div className="rounded-2xl bg-primary/10 border border-primary/20 px-4 py-2.5 max-w-[70%]">
<p className="text-sm">{text}</p>
</div>
</motion.div>
);
}
/** Toggle-chip for multi-select. */
function Chip({
label,
selected,
onClick,
}: {
label: string;
selected: boolean;
onClick: () => void;
}) {
return (
<Button
variant={selected ? 'default' : 'outline'}
size="sm"
className="rounded-full"
onClick={onClick}
>
{label}
</Button>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function OnboardingFlow({ profile }: OnboardingFlowProps) {
const [step, setStep] = useState<Step>('welcome');
// answers stores comma-joined values per field (supports multi-select)
const [answers, setAnswers] = useState<Record<string, string>>({});
// per-step selected chip sets (for multi-select toggle UI)
const [selected, setSelected] = useState<Record<string, Set<string>>>({});
const [freeTexts, setFreeTexts] = useState<Record<string, string>>({});
const [customInput, setCustomInput] = useState('');
const [reviewValues, setReviewValues] = useState<Record<string, string>>({});
const [editingField, setEditingField] = useState<string | null>(null);
const [editBuffer, setEditBuffer] = useState('');
const [normalizeError, setNormalizeError] = useState(false);
const [saveError, setSaveError] = useState(false);
const utils = trpc.useUtils();
const { notify, notifyError } = useNotify();
const normalizeMutation = trpc.auth.normalizeOnboarding.useMutation();
const updateMemoryMutation = trpc.auth.updateMemory.useMutation();
const displayName =
[profile.name, profile.surname].filter(Boolean).join(' ') ||
profile.email.split('@')[0];
// -- Chip toggle --
const toggleChip = useCallback((field: string, value: string) => {
setSelected((prev) => {
const set = new Set(prev[field] ?? []);
if (set.has(value)) set.delete(value);
else set.add(value);
return { ...prev, [field]: set };
});
}, []);
const isChipSelected = useCallback(
(field: string, value: string) => selected[field]?.has(value) ?? false,
[selected],
);
// -- Navigation helpers --
const goNext = useCallback(() => {
const idx = STEP_ORDER.indexOf(step);
if (idx >= 0 && idx < STEP_ORDER.length - 1) {
setCustomInput('');
setStep(STEP_ORDER[idx + 1]);
}
}, [step]);
const goBack = useCallback(() => {
const idx = STEP_ORDER.indexOf(step);
if (idx > 0) {
setCustomInput('');
setStep(STEP_ORDER[idx - 1]);
}
}, [step]);
/** Commit selections for a field and advance. */
const commitAndNext = useCallback(
(field: string) => {
const chips = selected[field];
const chipValues = chips ? [...chips] : [];
const custom = customInput.trim();
// Merge chip selections + optional custom input
const allValues = [...chipValues];
if (custom && !allValues.includes(custom)) allValues.push(custom);
if (allValues.length > 0) {
const joined = allValues.join(', ');
setAnswers((prev) => ({ ...prev, [field]: joined }));
// Track free text if custom input was used
if (custom) {
setFreeTexts((prev) => ({ ...prev, [field]: joined }));
}
}
setCustomInput('');
goNext();
},
[selected, customInput, goNext],
);
const handleSkip = useCallback(() => {
updateMemoryMutation.mutate(
{ memory: {}, markOnboarded: true },
{
onSuccess: () => void utils.auth.status.invalidate(),
},
);
}, [updateMemoryMutation, utils]);
/** Check if the current step has at least one selection. */
const hasSelection = useCallback(
(field: string) => {
const chips = selected[field];
return (chips && chips.size > 0) || customInput.trim().length > 0;
},
[selected, customInput],
);
// -- Reviewing step --
const startReview = useCallback(async () => {
setStep('reviewing');
const chipAnswers = { ...answers };
const freeTextAnswers: Record<string, string> = {};
for (const [key, val] of Object.entries(freeTexts)) {
if (val && answers[key] === val) {
freeTextAnswers[key] = val;
}
}
setReviewValues({ ...chipAnswers });
if (Object.keys(freeTextAnswers).length > 0) {
try {
const normalized = await normalizeMutation.mutateAsync({
inputs: freeTextAnswers,
});
setReviewValues((prev) => ({ ...prev, ...normalized }));
setNormalizeError(false);
} catch {
setNormalizeError(true);
}
}
}, [answers, freeTexts, normalizeMutation]);
const handleSave = useCallback(() => {
setSaveError(false);
const memory = { ...reviewValues };
const fullName = [profile.name, profile.surname].filter(Boolean).join(' ');
if (fullName) memory.user_name = fullName;
updateMemoryMutation.mutate(
{ memory, markOnboarded: true },
{
onSuccess: () => {
notify('success', 'toast.onboarding.completed', { descriptionKey: 'toast.onboarding.completedDescription' });
void utils.auth.status.invalidate();
},
onError: (err) => {
setSaveError(true);
notifyError('toast.onboarding.error', err);
},
},
);
}, [reviewValues, profile, updateMemoryMutation, utils, notify, notifyError]);
const handleEditStart = useCallback(
(key: string) => {
setEditingField(key);
setEditBuffer(reviewValues[key] ?? '');
},
[reviewValues],
);
const handleEditConfirm = useCallback(() => {
if (editingField) {
setReviewValues((prev) => ({ ...prev, [editingField]: editBuffer }));
setEditingField(null);
setEditBuffer('');
}
}, [editingField, editBuffer]);
// -- Past answers --
const fieldLabels: Record<string, string> = {
job_role: 'Role',
industry: 'Industry',
primary_use_case: 'Use case',
tone_preference: 'Tone',
language: 'Language',
};
const fieldOrder = ['job_role', 'industry', 'primary_use_case', 'tone_preference', 'language'];
const pastAnswers: { label: string; value: string }[] = [];
for (const key of fieldOrder) {
if (answers[key]) {
pastAnswers.push({ label: fieldLabels[key], value: answers[key] });
}
}
// -- Detected language (human-readable) --
const detectedLang = useMemo(() => {
const raw =
profile.memory?.language ??
(typeof navigator !== 'undefined' ? navigator.language : 'en');
// If it already looks like a display name (not a locale code), return as-is
if (raw.length > 5 || !raw.includes('-')) {
// Could be 'English', 'Italiano', or a bare code like 'en'
try {
const display = new Intl.DisplayNames([raw], { type: 'language' });
return display.of(raw) ?? raw;
} catch {
return raw;
}
}
try {
const display = new Intl.DisplayNames([raw], { type: 'language' });
return display.of(raw) ?? raw;
} catch {
return raw;
}
}, [profile.memory?.language]);
// -- Step index for progress indicator --
const stepIdx = STEP_ORDER.indexOf(step);
const showBack = stepIdx > 1; // show back from jobRole onwards (not on welcome)
// -- Render --
return (
<div className="flex h-full w-full items-center justify-center bg-background">
<div className="w-full max-w-xl px-6 py-10">
{/* Progress dots */}
{step !== 'welcome' && step !== 'done' && (
<div className="flex justify-center gap-1.5 mb-6">
{STEP_ORDER.slice(1, -1).map((s, i) => (
<div
key={s}
className={cn(
'h-1.5 rounded-full transition-all duration-300',
i < stepIdx - 1
? 'w-6 bg-primary'
: i === stepIdx - 1
? 'w-6 bg-primary'
: 'w-1.5 bg-muted-foreground/20',
)}
/>
))}
</div>
)}
<AnimatePresence mode="wait">
<motion.div
key={step}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={spring}
className="flex flex-col gap-4"
>
{/* Past answers (user bubbles) */}
{step !== 'welcome' && step !== 'reviewing' && (
<div className="flex flex-col gap-2 mb-2">
{pastAnswers.map(({ label, value }) => (
<UserBubble key={label} text={`${label}: ${value}`} />
))}
</div>
)}
{/* ── WELCOME ── */}
{step === 'welcome' && (
<>
<AIBubble>
<p>
Hi <span className="font-medium">{displayName}</span>! I&apos;m
your AI assistant. Let me learn a few things about you so I can
help better.
</p>
</AIBubble>
<div className="flex justify-end mt-2">
<Button onClick={goNext} size="sm">
Let&apos;s go <ChevronRight size={14} className="ml-1" />
</Button>
</div>
</>
)}
{/* ── JOB ROLE ── */}
{step === 'jobRole' && (
<>
<AIBubble>What&apos;s your role? Pick all that apply.</AIBubble>
<div className="flex flex-wrap gap-2 pl-11">
{JOB_ROLES.map((role) => (
<Chip
key={role}
label={role}
selected={isChipSelected('job_role', role)}
onClick={() => toggleChip('job_role', role)}
/>
))}
</div>
<div className="flex gap-2 pl-11">
<Input
placeholder="Type your own…"
value={customInput}
onChange={(e) => setCustomInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && hasSelection('job_role')) {
commitAndNext('job_role');
}
}}
className="h-8 text-sm"
/>
</div>
<StepNav
showBack={showBack}
onBack={goBack}
onNext={() => commitAndNext('job_role')}
canNext={hasSelection('job_role')}
onSkip={handleSkip}
/>
</>
)}
{/* ── INDUSTRY ── */}
{step === 'industry' && (
<>
<AIBubble>What industry do you work in? Pick all that apply.</AIBubble>
<div className="flex flex-wrap gap-2 pl-11">
{INDUSTRIES.map((ind) => (
<Chip
key={ind}
label={ind}
selected={isChipSelected('industry', ind)}
onClick={() => toggleChip('industry', ind)}
/>
))}
</div>
<div className="flex gap-2 pl-11">
<Input
placeholder="Type your own…"
value={customInput}
onChange={(e) => setCustomInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && hasSelection('industry')) {
commitAndNext('industry');
}
}}
className="h-8 text-sm"
/>
</div>
<StepNav
showBack={showBack}
onBack={goBack}
onNext={() => commitAndNext('industry')}
canNext={hasSelection('industry')}
onSkip={handleSkip}
/>
</>
)}
{/* ── USE CASE ── */}
{step === 'useCase' && (
<>
<AIBubble>How will you mainly use adiuvAI? Pick all that apply.</AIBubble>
<div className="flex flex-wrap gap-2 pl-11">
{USE_CASES.map((uc) => (
<Chip
key={uc}
label={uc}
selected={isChipSelected('primary_use_case', uc)}
onClick={() => toggleChip('primary_use_case', uc)}
/>
))}
</div>
<StepNav
showBack={showBack}
onBack={goBack}
onNext={() => commitAndNext('primary_use_case')}
canNext={hasSelection('primary_use_case')}
onSkip={handleSkip}
/>
</>
)}
{/* ── TONE ── */}
{step === 'tone' && (
<>
<AIBubble>How should I talk to you? Pick all that apply.</AIBubble>
<div className="flex flex-wrap gap-2 pl-11">
{TONES.map((t) => (
<Chip
key={t}
label={t}
selected={isChipSelected('tone_preference', t)}
onClick={() => toggleChip('tone_preference', t)}
/>
))}
</div>
<StepNav
showBack={showBack}
onBack={goBack}
onNext={() => commitAndNext('tone_preference')}
canNext={hasSelection('tone_preference')}
onSkip={handleSkip}
/>
</>
)}
{/* ── LANGUAGE ── */}
{step === 'language' && (
<>
<AIBubble>
I&apos;ll respond in <span className="font-medium">{detectedLang}</span>.
Want to change it?
</AIBubble>
<div className="flex flex-wrap gap-2 pl-11">
<Chip
label={`Keep ${detectedLang}`}
selected={isChipSelected('language', detectedLang)}
onClick={() => toggleChip('language', detectedLang)}
/>
</div>
<div className="flex gap-2 pl-11">
<Input
placeholder="Type a language…"
value={customInput}
onChange={(e) => setCustomInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && hasSelection('language')) {
// Commit language, then go to review
const chips = selected.language;
const chipValues = chips ? [...chips] : [];
const custom = customInput.trim();
const allValues = [...chipValues];
if (custom && !allValues.includes(custom)) allValues.push(custom);
if (allValues.length > 0) {
const joined = allValues.join(', ');
setAnswers((prev) => ({ ...prev, language: joined }));
if (custom) setFreeTexts((prev) => ({ ...prev, language: joined }));
}
setCustomInput('');
void startReview();
}
}}
className="h-8 text-sm"
/>
</div>
<StepNav
showBack={showBack}
onBack={goBack}
onNext={() => {
// Commit language, then go to review
const chips = selected.language;
const chipValues = chips ? [...chips] : [];
const custom = customInput.trim();
const allValues = [...chipValues];
if (custom && !allValues.includes(custom)) allValues.push(custom);
if (allValues.length > 0) {
const joined = allValues.join(', ');
setAnswers((prev) => ({ ...prev, language: joined }));
if (custom) setFreeTexts((prev) => ({ ...prev, language: joined }));
}
setCustomInput('');
void startReview();
}}
canNext={hasSelection('language')}
onSkip={handleSkip}
nextLabel="Review"
/>
</>
)}
{/* ── REVIEWING ── */}
{step === 'reviewing' && (
<>
<AIBubble>Here&apos;s what I&apos;ll remember about you.</AIBubble>
{normalizeError && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="ml-11 rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-2.5 text-xs text-amber-700 dark:text-amber-400"
>
Couldn&apos;t auto-tidy review and save as-is.
</motion.div>
)}
<Card className="ml-11 rounded-xl">
<CardContent className="px-5 py-4 flex flex-col gap-3">
{fieldOrder.map((key) => {
const value = reviewValues[key];
if (!value) return null;
const original = freeTexts[key];
const wasTidied = original && original !== value;
return (
<div key={key} className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground">
{fieldLabels[key]}
</p>
{editingField === key ? (
<div className="flex items-center gap-1 mt-0.5">
<Input
value={editBuffer}
onChange={(e) => setEditBuffer(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleEditConfirm();
if (e.key === 'Escape') setEditingField(null);
}}
className="h-7 text-sm"
autoFocus
/>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={handleEditConfirm}
>
<Check size={12} />
</Button>
</div>
) : (
<>
<p className="text-sm font-medium">{value}</p>
{wasTidied && (
<p className="text-xs text-muted-foreground/60 mt-0.5">
auto-tidied from &ldquo;{original}&rdquo;
</p>
)}
</>
)}
</div>
{editingField !== key && (
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 shrink-0"
onClick={() => handleEditStart(key)}
>
<Pencil size={12} />
</Button>
)}
</div>
);
})}
</CardContent>
</Card>
{normalizeMutation.isPending && (
<div className="ml-11 flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 size={14} className="animate-spin" />
Tidying up
</div>
)}
{saveError && (
<div className="ml-11 text-xs text-red-500">
Failed to save please try again.
</div>
)}
<div className="flex items-center gap-3 ml-11 mt-2">
<Button
variant="ghost"
size="sm"
onClick={goBack}
>
<ChevronLeft size={14} className="mr-1" /> Back
</Button>
<Button
onClick={handleSave}
size="sm"
disabled={updateMemoryMutation.isPending}
>
{updateMemoryMutation.isPending ? (
<Loader2 size={14} className="animate-spin mr-1.5" />
) : null}
Looks good save
</Button>
</div>
</>
)}
</motion.div>
</AnimatePresence>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Small sub-components
// ---------------------------------------------------------------------------
function StepNav({
showBack,
onBack,
onNext,
canNext,
onSkip,
nextLabel = 'Next',
}: {
showBack: boolean;
onBack: () => void;
onNext: () => void;
canNext: boolean;
onSkip: () => void;
nextLabel?: string;
}) {
return (
<div className="flex items-center gap-2 pl-11 mt-2">
{showBack && (
<Button variant="ghost" size="sm" onClick={onBack}>
<ChevronLeft size={14} className="mr-1" /> Back
</Button>
)}
<Button size="sm" onClick={onNext} disabled={!canNext}>
{nextLabel} <ChevronRight size={14} className="ml-1" />
</Button>
<button
onClick={onSkip}
className="text-xs text-muted-foreground hover:text-foreground transition-colors ml-auto"
>
Skip setup
</button>
</div>
);
}

View File

@@ -0,0 +1,25 @@
export const JOB_ROLES = [
'Developer',
'Designer',
'Consultant',
'Founder',
'Project Manager',
] as const;
export const INDUSTRIES = [
'Tech',
'Design',
'Consulting',
'Legal',
'Marketing',
'Education',
] as const;
export const USE_CASES = [
'Solo freelancer',
'Client manager',
'Team lead',
'Personal productivity',
] as const;
export const TONES = ['Casual', 'Formal', 'Concise', 'Detailed'] as const;

View File

@@ -1,168 +0,0 @@
import { useState, useMemo, useCallback } from 'react';
import { DragDropContext, Droppable, Draggable, type DropResult } from '@hello-pangea/dnd';
import { trpc } from '@/lib/trpc';
import { useFloatingChat } from '@/context/FloatingChatContext';
import { Badge } from '@/components/ui/badge';
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
import { EditTaskDialog } from '@/components/tasks/EditTaskDialog';
import { TaskDetailDialog } from '@/components/tasks/TaskDetailDialog';
const COLUMNS = [
{ id: 'todo', label: 'To Do' },
{ id: 'in_progress', label: 'In Progress' },
{ id: 'done', label: 'Completed' },
] as const;
type ColumnId = (typeof COLUMNS)[number]['id'];
type KanbanBoardProps = {
projectId: string;
newTaskOpen: boolean;
onNewTaskOpenChange: (open: boolean) => void;
};
export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: KanbanBoardProps) {
const { state: floatingState } = useFloatingChat();
const { data: tasksList } = trpc.tasks.list.useQuery({ projectId });
const utils = trpc.useUtils();
const updateTask = trpc.tasks.update.useMutation({
onSuccess: () => void utils.tasks.list.invalidate(),
});
const deleteTask = trpc.tasks.delete.useMutation({
onSuccess: () => void utils.tasks.list.invalidate(),
});
// Edit / view task dialog state
const [editTask, setEditTask] = useState<TaskItem | null>(null);
const [viewTask, setViewTask] = useState<TaskItem | null>(null);
// Group tasks by status (exclude unapproved AI suggestions)
const columns = useMemo(() => {
const tasks = (tasksList ?? []).filter(
(t) => !(t.isAiSuggested === 1 && t.isApproved === 0),
);
const grouped: Record<ColumnId, TaskItem[]> = {
todo: [],
in_progress: [],
done: [],
};
for (const task of tasks) {
const status = (task.status ?? 'todo') as ColumnId;
if (status in grouped) {
grouped[status].push(task);
} else {
grouped.todo.push(task);
}
}
return grouped;
}, [tasksList]);
const handleDragEnd = useCallback(
(result: DropResult) => {
const { destination, source, draggableId } = result;
if (!destination) return;
if (destination.droppableId === source.droppableId) return;
updateTask.mutate({
id: draggableId,
status: destination.droppableId,
});
},
[updateTask],
);
const handleToggle = useCallback(
(taskId: string, currentStatus: string | null) => {
const nextStatus =
currentStatus === 'todo' ? 'in_progress' :
currentStatus === 'in_progress' ? 'done' : 'todo';
updateTask.mutate({ id: taskId, status: nextStatus });
},
[updateTask],
);
return (
<>
<DragDropContext onDragEnd={handleDragEnd}>
<div className="grid grid-cols-3 gap-4">
{COLUMNS.map((col) => (
<div key={col.id} className="flex flex-col gap-3">
{/* Column header */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{col.label}</span>
<Badge variant="secondary" className="text-xs">
{columns[col.id].length}
</Badge>
</div>
{/* Droppable column */}
<Droppable droppableId={col.id}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={`flex flex-col gap-2 min-h-[120px] rounded-md transition-colors ${
snapshot.isDraggingOver ? 'bg-muted/50' : 'bg-muted/20'
}`}
>
{columns[col.id].map((task, index) => (
<Draggable
key={task.id}
draggableId={task.id}
index={index}
>
{(dragProvided) => (
<div
ref={dragProvided.innerRef}
{...dragProvided.draggableProps}
{...dragProvided.dragHandleProps}
>
<TaskRow
task={task}
onToggle={handleToggle}
onEdit={setEditTask}
onDelete={(id) => deleteTask.mutate({ id })}
onClick={setViewTask}
hideBreadcrumb
layoutId={
floatingState.morphTargetId === `task-morph-${task.id}`
? floatingState.morphTargetId
: undefined
}
/>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</div>
))}
</div>
</DragDropContext>
<NewTaskDialog
open={newTaskOpen}
onOpenChange={onNewTaskOpenChange}
defaultProjectId={projectId}
/>
<EditTaskDialog
task={editTask}
open={!!editTask}
onOpenChange={(open) => { if (!open) setEditTask(null); }}
/>
<TaskDetailDialog
task={viewTask}
open={!!viewTask}
onOpenChange={(open) => { if (!open) setViewTask(null); }}
onEdit={(task) => { setViewTask(null); setEditTask(task); }}
onDelete={(id) => { deleteTask.mutate({ id }); setViewTask(null); }}
/>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More