141 Commits

Author SHA1 Message Date
Roberto
4b80bcb53b fix(scouts): static import of scout-suggestion-handler + cloud scout UI polish
- backend-client.ts: replace lazy await import('../scouts/scout-suggestion-handler')
  with static import. Lazy import created a separate Vite chunk that re-evaluated
  side-effectful main-process modules → second IPC handler registration for
  'dialog:showOpenDialog' threw → scout proposal persist failed → no ack → no row.
- backend-client.ts: log parse failures w/ frame type + zod issues for diagnostics
- ScoutRow.tsx: cloud scouts show "Real-time" trigger label and hide "Run now"
  button (cloud runs are push/cron-driven, not user-triggered)
- scripts/check_scouts_db.py: inspect Electron SQLite for scout tables + suggestions

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 00:27:22 +02:00
Roberto
55c1bab7b1 fix(db): correct when timestamps on migrations 0007/0008 so migrator applies them 2026-06-11 00:09:42 +02:00
Roberto
60925da98c feat(scouts): cloud creation finalizes scout only at end (pending-session OAuth)
The creation flow no longer creates a scout at the Connect step. It starts a
server-side pending OAuth session (startGmailOAuthDraft), advances to the filter
step on the create-mode callback (carrying the returned sessionId), lists labels
from the session token, and creates the scout only at finalize. Abandoned/errored
attempts leave no orphan rows. The reconnect path (startGmailOAuth/gmailLabels)
is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 18:25:19 +02:00
Roberto
93674456ec fix(scouts): branch delete toast on result.success to avoid false 'deleted' 2026-06-10 18:16:59 +02:00
Roberto
a7b9d51268 docs(CLAUDE): note BackendClient camelCase/snake_case conversion footgun 2026-06-10 18:02:47 +02:00
Roberto
9733aa6a3a fix(scouts): read camelCased authorizeUrl from proxyGet in startGmailOAuth
proxyGet camelCases responses; reading data.authorize_url returned undefined,
causing shell.openExternal(undefined) -> 'conversion failure from undefined'.
2026-06-10 18:02:01 +02:00
Roberto
e8f56feaac fix(scouts): correct TemplateSelectCard shared/api-types import depth 2026-06-10 16:29:00 +02:00
Roberto
b6468c755f fix(scouts): hide settings header during scout creation flow 2026-06-10 16:25:43 +02:00
Roberto
6868d8813e i18n: add cloud scout creation + config keys (5 languages) 2026-06-10 16:23:28 +02:00
Roberto
486ff83a94 feat(scouts): disable Teams/Outlook template cards (coming soon) 2026-06-10 16:20:40 +02:00
Roberto
0a893d1929 refactor(scouts): rewrite CloudScoutConfigPanel for slim-model parity 2026-06-10 16:18:19 +02:00
Roberto
e6d3f9d7be feat(scouts): add CloudScoutCreationFlow Gmail slim flow 2026-06-10 16:16:38 +02:00
Roberto
3b41e8e7aa refactor(scouts): extract LocalScoutCreationFlow, stepper becomes router 2026-06-10 16:15:02 +02:00
Roberto
4979c2b7d9 feat(scouts): tRPC cloud input changes + gmailLabels + disconnectGmail 2026-06-10 16:13:12 +02:00
Roberto
7bd4cc9d9e feat(scouts): add cloud scout config fields to shared type 2026-06-10 16:11:50 +02:00
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
119 changed files with 14209 additions and 4742 deletions

View File

@@ -110,7 +110,7 @@ All use `temperature: 0.3`, streaming enabled. Provider management in `provider.
### Notes AI Navigation (aiSummary index)
Notes have `aiSummary` (≤250 char, nullable) and `aiSummaryUpdatedAt` columns. Generated by backend `POST /api/v1/agents/notes/summarize` (gpt-4o-mini, Langfuse `note_summary` prompt).
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).
- `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).
@@ -131,6 +131,7 @@ Tasks and checkpoints have `isAiSuggested` + `isApproved` columns. AI suggestion
## Config Notes
- **BackendClient case conversion (footgun)**: `backend-client.ts` `proxyGet`/`proxyPost`/`proxyPut` **camelCase the response** and **snake_case the request body**. The FastAPI BE speaks snake_case; the TS side speaks camelCase. So a BE response `{authorize_url}` arrives as `{authorizeUrl}` — reading `data.authorize_url` returns `undefined`. Always type the proxy generic with camelCase keys and read camelCase. (Real bug: `shell.openExternal(data.authorize_url)``undefined` → Electron throws `Error processing argument at index 0, conversion failure from undefined`.)
- Vite configs use `.mts` (not `.ts`) — avoids ESM/CJS conflicts with electron-forge
- `@/*` path alias → `src/renderer/*` (TypeScript + Vite + shadcn/ui)
- **shadcn/ui**: new-york style, neutral base color

6
.gitignore vendored
View File

@@ -98,3 +98,9 @@ dist-web/
.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,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

@@ -29,6 +29,10 @@ const config: ForgeConfig = {
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.

View File

@@ -0,0 +1,52 @@
"""Inspect Electron's SQLite for scout tables + suggestions.
Usage:
python adiuvAI/scripts/check_scouts_db.py
"""
import os
import sqlite3
import sys
DB_PATH = os.path.join(os.environ["APPDATA"], "adiuvAI", "adiuvai.db")
if not os.path.exists(DB_PATH):
print(f"DB not found: {DB_PATH}")
sys.exit(1)
c = sqlite3.connect(DB_PATH)
print(f"DB: {DB_PATH}\n")
print("=== scout_* tables ===")
for row in c.execute(
"select name from sqlite_master where type='table' and name like 'scout_%' order by name"
):
print(f" {row[0]}")
print("\n=== agent_* tables (should be empty post-rename) ===")
for row in c.execute(
"select name from sqlite_master where type='table' and name like 'agent_%' order by name"
):
print(f" {row[0]}")
print("\n=== Migration ledger (last 5) ===")
for row in c.execute(
"select hash, created_at from __drizzle_migrations order by id desc limit 5"
):
print(f" {row[0][:20]}... {row[1]}")
print("\n=== scout_suggestions rows (latest 5) ===")
try:
rows = list(
c.execute(
"select id, raw_subject, status, category, proposed_at "
"from scout_suggestions order by proposed_at desc limit 5"
)
)
if not rows:
print(" (empty)")
for row in rows:
print(f" {row}")
except sqlite3.OperationalError as e:
print(f" ERROR: {e}")

View File

@@ -3,7 +3,7 @@
*
* All AI intelligence lives on the backend. The Electron process:
* 1. Checks connectivity + auth status
* 2. Delegates to BackendClient.sendHomeRequest() / sendFloatingRequest()
* 2. Delegates to BackendClient.sendHomeRequest() / sendContextualRequest()
* which handle the WS lifecycle, tool-call ↔ DrizzleExecutor round-trips,
* and v3 stream event dispatch.
* 3. Forwards v3 typed stream frames to the renderer via IPC.
@@ -13,8 +13,6 @@ import { BrowserWindow } from 'electron';
import { getBackendClient, OfflineError, AuthExpiredError } from '../api/backend-client';
import { getAuthManager } from '../auth/auth-manager';
import { getStore } from '../store';
import type { WsFloatingRequest } from '../../shared/api-types';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
@@ -37,14 +35,12 @@ interface OrchestrateInput {
sender?: Electron.WebContents;
}
interface OrchestrateFloatingInput {
interface OrchestrateContextualInput {
message: string;
requestId?: string;
sessionId?: string;
scope: WsFloatingRequest['scope'];
conversationHistory?: WsFloatingRequest['conversationHistory'];
briefMode?: boolean;
briefingContext?: string;
scope: unknown;
conversationHistory?: Array<{ role: string; content: string }>;
sender?: Electron.WebContents;
}
@@ -127,22 +123,21 @@ export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateR
}
// ---------------------------------------------------------------------------
// Orchestrate Floating — Floating chat (public entry point)
// Orchestrate Contextual — Contextual sidebar chat (public entry point)
// ---------------------------------------------------------------------------
export async function orchestrateFloating(input: OrchestrateFloatingInput): Promise<OrchestrateResult> {
const { message, requestId, sessionId, scope, conversationHistory, briefMode, briefingContext, sender } = input;
export async function orchestrateContextual(input: OrchestrateContextualInput): Promise<OrchestrateResult> {
const { message, requestId, sessionId, scope, conversationHistory, sender } = input;
const check = await checkConnectivity();
if (!check.ok) return { response: '', error: check.error };
try {
const client = getBackendClient();
const { requestId: activeRequestId, promise } = client.sendFloatingRequest(message, scope, conversationHistory, requestId, sessionId, briefMode, briefingContext, {
const { requestId: activeRequestId, promise } = client.sendContextualRequest(message, scope, conversationHistory, requestId, sessionId, {
onStart: () => sendFrame(sender, { type: 'stream_start', requestId: activeRequestId }),
onText: (chunk) => sendFrame(sender, { type: 'stream_text', requestId: activeRequestId, chunk }),
onEnd: (mutations) => sendFrame(sender, { type: 'stream_end', requestId: activeRequestId, mutations: mutations as unknown[] | undefined }),
onDomain: (domain) => sendFrame(sender, { type: 'floating_domain', requestId: activeRequestId, domain }),
onError: () => sendFrame(sender, { type: 'stream_end', requestId: activeRequestId }),
});

View File

@@ -20,21 +20,20 @@
import { app } from 'electron';
import WebSocket from 'ws';
import { eq } from 'drizzle-orm';
import { getStore, getDeviceId, getLocalAgents, getFormatPrefs } from '../store';
import { getStore, getDeviceId, getLocalScouts, getFormatPrefs } from '../store';
import { detectFormatPrefs } from '../auth/locale-defaults';
import { getAuthManager } from '../auth/auth-manager';
import { toSnakeCase, toCamelCase } from '../../shared/casing';
import { handleScoutProposal } from '../scouts/scout-suggestion-handler';
import {
WsServerFrameSchema,
} from '../../shared/api-types';
import type {
WsToolResult,
WsFloatingRequest,
WsFloatingDomain,
} from '../../shared/api-types';
import { DrizzleExecutor } from './drizzle-executor';
import { getDb } from '../db';
import { agentRuns, agentRunActions } from '../db/schema';
import { scoutRuns, scoutRunActions } from '../db/schema';
// ---------------------------------------------------------------------------
// Agent run logging helpers
@@ -62,10 +61,10 @@ async function recordRunAction(
entityTitle: string | null,
): Promise<void> {
try {
await getDb().insert(agentRunActions).values({
await getDb().insert(scoutRunActions).values({
id: crypto.randomUUID(),
runId,
agentId,
scoutId: agentId,
verb,
entityType,
entityId: entityId ?? null,
@@ -163,6 +162,16 @@ export class ServerError extends Error {
}
}
export class QuotaError extends Error {
constructor(
public readonly reason: 'max_files' | 'monthly_tokens',
message: string,
) {
super(message);
this.name = 'QuotaError';
}
}
// ---------------------------------------------------------------------------
// V3 stream listener types
// ---------------------------------------------------------------------------
@@ -171,7 +180,6 @@ interface StreamListener {
onStart: () => void;
onText: (chunk: string) => void;
onEnd: (mutations?: unknown) => void;
onDomain: (domain: WsFloatingDomain['domain']) => void;
onError: (err: Error) => void;
resolve: () => void;
reject: (err: Error) => void;
@@ -183,6 +191,12 @@ interface JourneyListener {
reject: (err: Error) => void;
}
export interface IndexSessionListener {
onFileResult: (frame: { relPath: string; summary: string | null; tokensUsed: number; error?: string }) => void;
onProgress: (frame: { processed: number; total: number }) => void;
onDone: (status: 'completed' | 'cancelled' | 'quota_exceeded' | 'error') => void;
}
// ---------------------------------------------------------------------------
// BackendClient
// ---------------------------------------------------------------------------
@@ -206,6 +220,9 @@ export class BackendClient {
/** Journey reply listeners keyed by sessionId. */
private journeyListeners: Map<string, JourneyListener> = new Map();
/** Index session listeners keyed by sessionId. */
private indexListeners: Map<string, IndexSessionListener> = new Map();
// eslint-disable-next-line @typescript-eslint/no-empty-function
private constructor() {}
@@ -273,7 +290,6 @@ export class BackendClient {
this.streamListeners.delete(activeRequestId);
resolve();
},
onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
onError: (err) => {
callbacks?.onError?.(err);
this.streamListeners.delete(activeRequestId);
@@ -333,7 +349,6 @@ export class BackendClient {
this.streamListeners.delete(activeRequestId);
resolve();
},
onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
onError: (err) => {
callbacks?.onError?.(err);
this.streamListeners.delete(activeRequestId);
@@ -372,72 +387,6 @@ export class BackendClient {
return { requestId: activeRequestId, promise };
}
/**
* Send a floating chat request over the persistent device WS.
* Same listener pattern as `sendHomeRequest`.
*/
sendFloatingRequest(
message: string,
scope: WsFloatingRequest['scope'],
conversationHistory?: WsFloatingRequest['conversationHistory'],
requestId?: string,
sessionId?: string,
briefMode?: boolean,
briefingContext?: string,
callbacks?: Partial<Omit<StreamListener, 'resolve' | 'reject'>>,
): { requestId: string; promise: Promise<void> } {
const activeRequestId = requestId ?? crypto.randomUUID();
const promise = new Promise<void>((resolve, reject) => {
this.streamListeners.set(activeRequestId, {
onStart: callbacks?.onStart ?? ((): void => { /* no-op */ }),
onText: callbacks?.onText ?? ((): void => { /* no-op */ }),
onEnd: (mutations) => {
callbacks?.onEnd?.(mutations);
this.streamListeners.delete(activeRequestId);
resolve();
},
onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
onError: (err) => {
callbacks?.onError?.(err);
this.streamListeners.delete(activeRequestId);
reject(err);
},
resolve,
reject,
});
const ws = this.persistentWs;
if (!ws || ws.readyState !== WebSocket.OPEN) {
this.streamListeners.delete(activeRequestId);
reject(new OfflineError('Persistent WS not connected'));
return;
}
const rawPrefs = getFormatPrefs() ?? detectFormatPrefs();
const floatingPayload = toSnakeCase({
type: 'floating_request',
requestId: activeRequestId,
sessionId,
message,
scope,
conversationHistory,
briefMode: briefMode ?? false,
briefingContext: briefingContext ?? null,
formatPrefs: {
timezone: rawPrefs.timezone,
dateFormat: rawPrefs.dateFormat,
timeFormat: rawPrefs.timeFormat,
locale: app.getLocale(),
nowIso: new Date().toISOString(),
},
});
logWsSend(floatingPayload);
ws.send(JSON.stringify(floatingPayload));
});
return { requestId: activeRequestId, promise };
}
/**
* Send a task brief research request over the persistent device WS.
* Backend runs a deep-research Stage 1 agent and streams the briefing.
@@ -458,7 +407,6 @@ export class BackendClient {
this.streamListeners.delete(activeRequestId);
resolve();
},
onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
onError: (err) => {
callbacks?.onError?.(err);
this.streamListeners.delete(activeRequestId);
@@ -564,6 +512,150 @@ export class BackendClient {
});
}
// -------------------------------------------------------------------------
// Index session WS methods
// -------------------------------------------------------------------------
/**
* Register a listener for an index session. Must be called before sending
* the first `index_session_start` for that sessionId. Listener is auto-
* removed when `index_session_done` is received.
*/
registerIndexSession(sessionId: string, listener: IndexSessionListener): void {
this.indexListeners.set(sessionId, listener);
}
/**
* Send the opening `index_session_start` frame. Must be called after
* `registerIndexSession`. Throws OfflineError if the WS is not connected.
*/
sendIndexSessionStart(sessionId: string, projectId: string, totalFiles: number): void {
const ws = this.persistentWs;
if (!ws || ws.readyState !== WebSocket.OPEN) throw new OfflineError('Persistent WS not connected');
const payload = toSnakeCase({ type: 'index_session_start', sessionId, projectId, totalFiles });
logWsSend(payload);
ws.send(JSON.stringify(payload));
}
/**
* Send one batch of files (typically up to 5).
*/
sendIndexFileBatch(
sessionId: string,
files: Array<{
relPath: string;
kind: 'text' | 'image' | 'pdf' | 'docx';
content: string;
ext?: string;
mime?: string;
sizeBytes: number;
mtimeMs: number;
}>,
): void {
const ws = this.persistentWs;
if (!ws || ws.readyState !== WebSocket.OPEN) throw new OfflineError('Persistent WS not connected');
const payload = toSnakeCase({ type: 'index_file_batch', sessionId, files });
logWsSend(payload);
ws.send(JSON.stringify(payload));
}
/**
* Cancel an in-flight index session.
*/
sendIndexSessionCancel(sessionId: string): void {
const ws = this.persistentWs;
if (!ws || ws.readyState !== WebSocket.OPEN) return; // best-effort
const payload = toSnakeCase({ type: 'index_session_cancel', sessionId });
logWsSend(payload);
ws.send(JSON.stringify(payload));
}
// -------------------------------------------------------------------------
// Contextual chat — send via persistent device WS
// -------------------------------------------------------------------------
/**
* Send a contextual chat request over the persistent device WS.
* Same listener pattern as `sendHomeRequest` but uses the
* `contextual_request` frame type.
*/
sendContextualRequest(
message: string,
scope: unknown,
conversationHistory?: Array<{ role: string; content: string }>,
requestId?: string,
sessionId?: string,
callbacks?: Partial<Omit<StreamListener, 'resolve' | 'reject'>>,
): { requestId: string; promise: Promise<void> } {
const activeRequestId = requestId ?? crypto.randomUUID();
const promise = new Promise<void>((resolve, reject) => {
this.streamListeners.set(activeRequestId, {
onStart: callbacks?.onStart ?? ((): void => { /* no-op */ }),
onText: callbacks?.onText ?? ((): void => { /* no-op */ }),
onEnd: (mutations) => {
callbacks?.onEnd?.(mutations);
this.streamListeners.delete(activeRequestId);
resolve();
},
onError: (err) => {
callbacks?.onError?.(err);
this.streamListeners.delete(activeRequestId);
reject(err);
},
resolve,
reject,
});
const ws = this.persistentWs;
if (!ws || ws.readyState !== WebSocket.OPEN) {
this.streamListeners.delete(activeRequestId);
reject(new OfflineError('Persistent WS not connected'));
return;
}
const rawPrefs = getFormatPrefs() ?? detectFormatPrefs();
const contextualPayload = toSnakeCase({
type: 'contextual_request',
requestId: activeRequestId,
sessionId,
message,
scope,
conversationHistory,
formatPrefs: {
timezone: rawPrefs.timezone,
dateFormat: rawPrefs.dateFormat,
timeFormat: rawPrefs.timeFormat,
locale: app.getLocale(),
nowIso: new Date().toISOString(),
},
});
logWsSend(contextualPayload);
ws.send(JSON.stringify(contextualPayload));
});
return { requestId: activeRequestId, promise };
}
/**
* Send a contextual scope update over the persistent device WS.
* Fire-and-forget — backend responds with `contextual_scope_ack` which
* we do not need to handle on the Electron side.
*/
sendContextualScopeUpdate(args: { sessionId: string; scope: unknown }): void {
const ws = this.persistentWs;
if (!ws || ws.readyState !== WebSocket.OPEN) {
console.warn('[DeviceWS] sendContextualScopeUpdate: WS not connected — dropping scope update.');
return;
}
const payload = toSnakeCase({
type: 'contextual_scope_update',
sessionId: args.sessionId,
scope: args.scope,
});
logWsSend(payload);
ws.send(JSON.stringify(payload));
}
// -------------------------------------------------------------------------
// HTTP utilities
// -------------------------------------------------------------------------
@@ -678,6 +770,72 @@ export class BackendClient {
);
}
// -------------------------------------------------------------------------
// Billing quota pre-flight
// -------------------------------------------------------------------------
/**
* Pre-flight quota check for folder indexing.
*
* Calls `POST /api/v1/billing/quota/check` with `{ feature: "folder_index",
* estimated_files: estimatedFiles }`.
*
* Returns `{ ok: true }` when the backend allows the operation.
* Throws `QuotaError` when the backend responds with HTTP 402.
* Propagates `AuthExpiredError` / `OfflineError` on auth / network failure.
*/
async checkFolderQuota(estimatedFiles: number): Promise<{ ok: true }> {
const token = await getAuthManager().getAccessToken();
if (!token) throw new AuthExpiredError();
const url = `${this.baseUrl}/api/v1/billing/quota/check`;
const bodyPayload = { feature: 'folder_index', estimated_files: estimatedFiles };
logHttp('POST', url, bodyPayload);
let res: Response;
try {
res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(bodyPayload),
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
});
} catch (err) {
throw new OfflineError(err instanceof Error ? err.message : 'Network error');
}
logHttpResponse('POST', url, res.status);
if (res.ok) return { ok: true };
if (res.status === 402) {
let detail: { reason: 'max_files' | 'monthly_tokens'; message: string } | null = null;
try {
const body = await res.json() as { detail?: { reason: string; message: string } };
if (body.detail?.reason && body.detail?.message) {
detail = {
reason: body.detail.reason as 'max_files' | 'monthly_tokens',
message: body.detail.message,
};
}
} catch { /* ignore parse errors */ }
const reason = detail?.reason ?? 'max_files';
const message = detail?.message ?? 'Quota exceeded';
throw new QuotaError(reason, message);
}
// Other error codes
const text = await res.text().catch(() => '');
const msg = `${res.status} ${res.statusText}${text ? `: ${text}` : ''}`;
if (res.status === 401) throw new AuthExpiredError(msg);
if (res.status === 429) throw new RateLimitError(msg);
if (res.status >= 500) throw new ServerError(msg, res.status);
throw new Error(msg);
}
// -------------------------------------------------------------------------
/**
@@ -744,14 +902,14 @@ export class BackendClient {
this.reconnectAttempt = 0;
console.log('[DeviceWS] Connected.');
// Read enabled local agent IDs from local storage
// Read enabled local scout IDs from local storage
const deviceId = getDeviceId();
const agentIds = getLocalAgents()
.filter((a) => a.enabled)
.map((a) => a.id);
const scoutIds = getLocalScouts()
.filter((s) => s.enabled)
.map((s) => s.id);
ws.send(JSON.stringify(toSnakeCase({ type: 'device_hello', deviceId, agentIds })));
console.log(`[DeviceWS] Sent device_hello (deviceId=${deviceId}, agents=${agentIds.length}).`);
ws.send(JSON.stringify(toSnakeCase({ type: 'device_hello', deviceId, scoutIds })));
console.log(`[DeviceWS] Sent device_hello (deviceId=${deviceId}, scouts=${scoutIds.length}).`);
this.startHeartbeat(ws);
});
@@ -765,8 +923,13 @@ export class BackendClient {
}
logWsRecv(parsed);
const frame = WsServerFrameSchema.safeParse(toCamelCase(parsed));
if (!frame.success) return;
const camel = toCamelCase(parsed);
const frame = WsServerFrameSchema.safeParse(camel);
if (!frame.success) {
// eslint-disable-next-line no-console
console.error('[ws] frame parse FAILED. type=', (camel as { type?: string })?.type, 'issues=', JSON.stringify(frame.error.issues));
return;
}
// Any incoming frame resets the pong timeout
this.clearPongTimer();
@@ -827,20 +990,14 @@ export class BackendClient {
break;
}
case 'floating_domain': {
const listener = this.streamListeners.get(frame.data.requestId);
listener?.onDomain(frame.data.domain);
break;
}
case 'run_complete': {
const { runContext, status } = frame.data;
void (async () => {
try {
const db = getDb();
await db.update(agentRuns)
await db.update(scoutRuns)
.set({ status: status === 'success' ? 'completed' : status === 'partial' ? 'partial' : 'failed', completedAt: Date.now() })
.where(eq(agentRuns.id, runContext.runId));
.where(eq(scoutRuns.id, runContext.runId));
} catch (err) {
console.warn('[RunLog] Failed to close run:', err);
}
@@ -861,6 +1018,50 @@ export class BackendClient {
}
break;
}
case 'index_file_result': {
const lis = this.indexListeners.get(frame.data.sessionId);
lis?.onFileResult({
relPath: frame.data.relPath,
summary: frame.data.summary ?? null,
tokensUsed: frame.data.tokensUsed,
error: frame.data.error,
});
break;
}
case 'index_session_progress': {
const lis = this.indexListeners.get(frame.data.sessionId);
lis?.onProgress({ processed: frame.data.processed, total: frame.data.total });
break;
}
case 'index_session_done': {
const lis = this.indexListeners.get(frame.data.sessionId);
if (lis) {
lis.onDone(frame.data.status);
this.indexListeners.delete(frame.data.sessionId);
}
break;
}
case 'scout_proposal': {
const proposal = frame.data.proposal;
void (async () => {
try {
await handleScoutProposal(proposal);
// Ack only on successful persist — if this fails, BE will re-deliver on next reconnect.
if (ws.readyState === WebSocket.OPEN) {
const ack = toSnakeCase({ type: 'scout_proposal_ack', proposalId: proposal.id });
logWsSend(ack);
ws.send(JSON.stringify(ack));
}
} catch (err) {
console.error('[scout-proposal] persist failed, not acking:', err);
}
})();
break;
}
}
});
@@ -895,6 +1096,8 @@ export class BackendClient {
}
this.journeyListeners.clear();
}
// Index session listeners are fire-and-forget; just drop them on disconnect.
this.indexListeners.clear();
if (this.shouldReconnect) {
this.scheduleReconnect();

View File

@@ -13,9 +13,9 @@
import * as fs from 'fs';
import * as path from 'path';
import { eq, and, or, like, isNull, asc, desc, gte, lte, sql, SQL } from 'drizzle-orm';
import { eq, and, or, like, isNull, asc, desc, gte, lte, inArray, sql, SQL } from 'drizzle-orm';
import { getDb } from '../db';
import { tasks, projects, clients, timelineEvents, notes, noteEdits, taskComments } from '../db/schema';
import { tasks, projects, clients, timelineEvents, notes, noteEdits, taskComments, projectFolderFiles } from '../db/schema';
import type { WsToolCall } from '../../shared/api-types';
// ---------------------------------------------------------------------------
@@ -31,6 +31,7 @@ const TABLE_REGISTRY = {
timelineEvents,
// Alias: the backend sends "timelines" as the table name
timelines: timelineEvents,
projectFolderFiles,
} as const;
type TableName = keyof typeof TABLE_REGISTRY;
@@ -99,6 +100,20 @@ function buildConditions(
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]));
}
@@ -187,6 +202,14 @@ export class DrizzleExecutor {
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}"`);
}
@@ -436,4 +459,209 @@ export class DrizzleExecutor {
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,8 @@
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';
@@ -10,147 +12,81 @@ let _dbPath: string | null = null;
/** Raw better-sqlite3 instance (needed for .backup() API). */
let _rawSqlite: Database.Database | null = null;
// 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
);
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,
created_at INTEGER NOT NULL,
completed_at INTEGER
);
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
project_id TEXT,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
ai_summary TEXT,
ai_summary_updated_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS note_edits (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL,
type TEXT NOT NULL,
anchor_before TEXT,
anchor_text TEXT,
proposed_content TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
agent_id TEXT,
run_id TEXT,
reasoning TEXT,
created_at INTEGER NOT NULL,
resolved_at INTEGER
);
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
);
CREATE TABLE IF NOT EXISTS timeline_events (
id TEXT PRIMARY KEY,
project_id TEXT,
title TEXT NOT NULL,
date INTEGER NOT NULL,
end_date INTEGER,
type TEXT NOT NULL DEFAULT 'milestone',
is_completed INTEGER NOT NULL DEFAULT 0,
is_ai_suggested INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
completed_at INTEGER
);
CREATE TABLE IF NOT EXISTS timeline_event_dependencies (
id TEXT PRIMARY KEY,
from_event_id TEXT NOT NULL,
to_event_id TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sync_queue (
id TEXT PRIMARY KEY,
action TEXT NOT NULL,
payload TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending',
retries INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_attempt_at INTEGER
);
CREATE TABLE IF NOT EXISTS agent_runs (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'running',
started_at INTEGER NOT NULL,
completed_at INTEGER
);
CREATE TABLE IF NOT EXISTS agent_run_actions (
id TEXT PRIMARY KEY,
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
);
CREATE TABLE IF NOT EXISTS task_briefings (
task_id TEXT PRIMARY KEY,
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
);
CREATE TABLE IF NOT EXISTS task_brief_chats (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
is_error INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT 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');
_dbPath = path.join(userDataPath, 'adiuvai.db');
@@ -162,23 +98,12 @@ export function initDb(): DbInstance {
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 DROP COLUMN is_approved'); } catch { /* already dropped or not supported */ }
try {
sqlite.exec("ALTER TABLE timeline_events ADD COLUMN type TEXT NOT NULL DEFAULT 'milestone'");
// Backfill: existing multi-day events become activities, single-day stay as milestones
sqlite.exec("UPDATE timeline_events SET type = 'activity' WHERE end_date IS NOT NULL AND type = 'milestone'");
} catch { /* column already exists */ }
try { sqlite.exec('ALTER TABLE tasks ADD COLUMN completed_at INTEGER'); } catch { /* already exists */ }
try { sqlite.exec('ALTER TABLE timeline_events ADD COLUMN completed_at INTEGER'); } catch { /* already exists */ }
try { sqlite.exec('ALTER TABLE notes ADD COLUMN ai_summary TEXT'); } catch { /* already exists */ }
try { sqlite.exec('ALTER TABLE notes ADD COLUMN ai_summary_updated_at INTEGER'); } catch { /* already exists */ }
const migrationsFolder = resolveMigrationsFolder();
bootstrapMigrationsLedger(sqlite, migrationsFolder);
dbInstance = drizzle(sqlite, { schema });
migrate(dbInstance, { migrationsFolder });
return dbInstance;
}

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,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

@@ -29,6 +29,41 @@
"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": 1778777200000,
"tag": "0007_scouts_rename",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1778777300000,
"tag": "0008_scout_suggestions",
"breakpoints": true
}
]
}

View File

@@ -2,7 +2,7 @@
* Notes AI summary backfill.
*
* On startup, scans notes with a null ai_summary and generates summaries
* via the backend `POST /api/v1/agents/notes/summarize` endpoint.
* 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.
@@ -44,7 +44,7 @@ export async function backfillNoteSummaries(): Promise<void> {
const note = pending[i]!;
try {
const result = await client.proxyPost<{ summary: string }>(
'/api/v1/agents/notes/summarize',
'/api/v1/scouts/notes/summarize',
{ title: note.title, content: note.content },
);
const summary = result.summary?.trim() ?? '';

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,6 +33,7 @@ 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),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
completedAt: integer('completed_at', { mode: 'number' }),
@@ -63,6 +70,21 @@ export const notes = sqliteTable('notes', {
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(),
@@ -86,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>;
@@ -102,6 +134,9 @@ 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>;
@@ -134,18 +169,18 @@ export const taskBriefChats = sqliteTable('task_brief_chats', {
export type TaskBriefChat = InferSelectModel<typeof taskBriefChats>;
export type NewTaskBriefChat = InferInsertModel<typeof taskBriefChats>;
export const agentRuns = sqliteTable('agent_runs', {
export const scoutRuns = sqliteTable('scout_runs', {
id: text('id').primaryKey(),
agentId: text('agent_id').notNull(),
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 agentRunActions = sqliteTable('agent_run_actions', {
export const scoutRunActions = sqliteTable('scout_run_actions', {
id: text('id').primaryKey(),
runId: text('run_id').notNull(),
agentId: text('agent_id').notNull(),
scoutId: text('scout_id').notNull(),
/** 'created' | 'updated' | 'deleted' | 'commented' */
verb: text('verb').notNull(),
/** 'task' | 'note' | 'project' | 'timeline' | 'comment' */
@@ -155,10 +190,54 @@ export const agentRunActions = sqliteTable('agent_run_actions', {
createdAt: integer('created_at', { mode: 'number' }).notNull(),
});
export type AgentRun = InferSelectModel<typeof agentRuns>;
export type NewAgentRun = InferInsertModel<typeof agentRuns>;
export type AgentRunAction = InferSelectModel<typeof agentRunActions>;
export type NewAgentRunAction = InferInsertModel<typeof agentRunActions>;
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

@@ -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

@@ -8,8 +8,9 @@ import { getAuthManager } from './auth/auth-manager';
import { getBackendClient } from './api/backend-client';
import { getStore } from './store';
import { startBriefScheduler, stopBriefScheduler } from './ai/orchestrator';
import { startAgentScheduler, stopAgentScheduler } from './agents/agent-scheduler';
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) {
@@ -33,10 +34,23 @@ if (process.defaultApp) {
/**
* 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 });
}
}
}
@@ -109,6 +123,13 @@ ipcMain.handle('dialog:showOpenDialog', (_event, options: Electron.OpenDialogOpt
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.
@@ -133,13 +154,15 @@ app.on('ready', () => {
.catch((err) => console.error('[DeviceWS] Startup connect failed:', err));
startBriefScheduler();
startAgentScheduler();
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();
stopAgentScheduler();
stopScoutScheduler();
getBackendClient().disconnectPersistent();
});

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 };
}),
});

View File

@@ -2,17 +2,23 @@ import { initTRPC } from '@trpc/server';
import { z } from 'zod';
import { eq, asc, desc, inArray, and, or, like, sql, gte, lte } from 'drizzle-orm';
import { alias } from 'drizzle-orm/sqlite-core';
import { dialog, shell } from 'electron';
import { randomUUID } from 'node:crypto';
import { stat } from 'node:fs/promises';
import { getDb } from '../db';
import { clients, projects, tasks, timelineEvents, timelineEventDependencies, notes, noteEdits, taskComments, agentRuns, agentRunActions, taskBriefings, taskBriefChats } from '../db/schema';
import { clients, projects, tasks, timelineEvents, timelineEventDependencies, notes, noteEdits, taskComments, taskAttachments, scoutRuns, scoutRunActions, taskBriefings, taskBriefChats } from '../db/schema';
import { copyIntoTask, deleteStored, absolutePath, deleteTaskDir } from '../attachments/storage';
import { createHash } from 'crypto';
import { getStore, getDeviceId, getLocalAgents, getLocalAgent, saveLocalAgent, deleteLocalAgent, getFormatPrefs, setFormatPrefs, getUiLanguage, setUiLanguage, getTimelineZoom, setTimelineZoom } from '../store';
import type { LocalAgentLocalConfig } from '../store';
import { getStore, getDeviceId, getLocalScouts, getLocalScout, saveLocalScout, deleteLocalScout, getFormatPrefs, setFormatPrefs, getUiLanguage, setUiLanguage, getTimelineZoom, setTimelineZoom } from '../store';
import type { LocalScoutConfig } from '../store';
import { getBackendClient } from '../api/backend-client';
import type { AgentCatalogItem, CloudAgentConfig, AgentRunLog } from '../../shared/api-types';
import { orchestrate, orchestrateFloating, orchestrateTaskBriefResearch, dailyBrief, getCachedBrief, invalidateBriefCache } from '../ai/orchestrator';
import type { AgentCatalogItem, CloudScoutConfig, AgentRunLog } from '../../shared/api-types';
import { orchestrate, orchestrateContextual, orchestrateTaskBriefResearch, dailyBrief, getCachedBrief, invalidateBriefCache } from '../ai/orchestrator';
import { getAuthManager, AuthError } from '../auth/auth-manager';
import { detectFormatPrefs, detectLanguage } from '../auth/locale-defaults';
import type { TRPCContext } from '../ipc';
import { projectFoldersRouter } from './projectFolders';
import { aiChatRouter } from './ai-chat';
const t = initTRPC.context<TRPCContext>().create();
@@ -290,6 +296,7 @@ const tasksRouter = router({
priority: tasks.priority,
assignee: tasks.assignee,
dueDate: tasks.dueDate,
estimate: tasks.estimate,
isAiSuggested: tasks.isAiSuggested,
createdAt: tasks.createdAt,
completedAt: tasks.completedAt,
@@ -391,6 +398,7 @@ const tasksRouter = router({
priority: tasks.priority,
assignee: tasks.assignee,
dueDate: tasks.dueDate,
estimate: tasks.estimate,
isAiSuggested: tasks.isAiSuggested,
createdAt: tasks.createdAt,
completedAt: tasks.completedAt,
@@ -414,6 +422,7 @@ const tasksRouter = router({
priority: z.string().optional(),
assignees: z.array(z.string()).optional(),
dueDate: z.number().optional(),
estimate: z.number().int().nullable().optional(),
projectId: z.string().optional(),
isAiSuggested: z.number().optional(),
}))
@@ -429,6 +438,7 @@ const tasksRouter = router({
priority: input.priority ?? 'medium',
assignee: input.assignees?.length ? JSON.stringify(input.assignees) : null,
dueDate: input.dueDate ?? null,
estimate: input.estimate ?? null,
projectId: input.projectId ?? null,
isAiSuggested: input.isAiSuggested ?? 0,
createdAt: now,
@@ -447,6 +457,7 @@ const tasksRouter = router({
priority: z.string().optional(),
assignees: z.array(z.string()).optional(),
dueDate: z.number().optional(),
estimate: z.number().int().nullable().optional(),
projectId: z.string().optional(),
}))
.mutation(({ input }) => {
@@ -464,6 +475,7 @@ const tasksRouter = router({
priority: string;
assignee: string | null;
dueDate: number | null;
estimate: number | null;
projectId: string | null;
completedAt: number | null;
}> = {};
@@ -472,6 +484,7 @@ const tasksRouter = router({
if (input.priority !== undefined) set.priority = input.priority;
if (input.assignees !== undefined) set.assignee = input.assignees.length ? JSON.stringify(input.assignees) : null;
if (input.dueDate !== undefined) set.dueDate = input.dueDate;
if (input.estimate !== undefined) set.estimate = input.estimate;
if (input.projectId !== undefined) set.projectId = input.projectId;
if (input.status !== undefined) {
set.status = input.status;
@@ -497,16 +510,25 @@ const tasksRouter = router({
delete: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(({ input }) => {
const prev = getDb()
.mutation(async ({ input }) => {
const db = getDb();
const prev = db
.select({ dueDate: tasks.dueDate, status: tasks.status, completedAt: tasks.completedAt })
.from(tasks)
.where(eq(tasks.id, input.id))
.all()[0];
getDb().delete(tasks).where(eq(tasks.id, input.id)).run();
getDb().delete(taskBriefings).where(eq(taskBriefings.taskId, input.id)).run();
getDb().delete(taskBriefChats).where(eq(taskBriefChats.taskId, input.id)).run();
// Cascade attachments: unlink files, delete rows, remove task directory
const atts = db.select().from(taskAttachments).where(eq(taskAttachments.taskId, input.id)).all();
for (const a of atts) {
await deleteStored(a.storedPath);
}
db.delete(taskAttachments).where(eq(taskAttachments.taskId, input.id)).run();
await deleteTaskDir(input.id);
db.delete(tasks).where(eq(tasks.id, input.id)).run();
db.delete(taskBriefings).where(eq(taskBriefings.taskId, input.id)).run();
db.delete(taskBriefChats).where(eq(taskBriefChats.taskId, input.id)).run();
if (isBriefRelevantTask(prev?.dueDate, prev?.status, prev?.completedAt)) invalidateBriefCache();
return { success: true as const };
}),
@@ -908,25 +930,20 @@ const aiRouter = router({
content: z.string(),
})).optional(),
sessionId: z.string().optional(),
mode: z.enum(['home', 'floating']).optional(),
scope: z.object({
type: z.enum(['task', 'project', 'note', 'timeline']),
id: z.string().optional(),
}).optional(),
mode: z.enum(['contextual']).optional(),
scope: z.unknown().optional(),
briefMode: z.boolean().optional(),
briefingContext: z.string().optional(),
}))
.mutation(async ({ input, ctx }) => {
try {
if (input.mode === 'floating' && input.scope) {
return await orchestrateFloating({
if (input.mode === 'contextual') {
return await orchestrateContextual({
message: input.message,
requestId: input.requestId,
sessionId: input.sessionId,
scope: input.scope,
conversationHistory: input.conversationHistory,
briefMode: input.briefMode,
briefingContext: input.briefingContext,
conversationHistory: input.conversationHistory as Array<{ role: string; content: string }> | undefined,
sender: ctx.sender,
});
}
@@ -1068,12 +1085,12 @@ const aiRouter = router({
});
// ---------------------------------------------------------------------------
// Agent router — proxy to backend agent management API
// Scout router — proxy to backend scout management API
// ---------------------------------------------------------------------------
const agentLocalRouter = router({
const scoutLocalRouter = router({
list: publicProcedure.query(() => {
return getLocalAgents();
return getLocalScouts();
}),
create: publicProcedure
@@ -1085,7 +1102,7 @@ const agentLocalRouter = router({
scheduleCron: z.string(),
}))
.mutation(({ input }) => {
const agent: LocalAgentLocalConfig = {
const scout: LocalScoutConfig = {
id: crypto.randomUUID(),
name: input.name,
directory: input.directory,
@@ -1095,8 +1112,8 @@ const agentLocalRouter = router({
enabled: true,
lastRunAt: null,
};
saveLocalAgent(agent);
return { data: agent, error: null };
saveLocalScout(scout);
return { data: scout, error: null };
}),
update: publicProcedure
@@ -1110,11 +1127,11 @@ const agentLocalRouter = router({
enabled: z.boolean().optional(),
}))
.mutation(({ input }) => {
const existing = getLocalAgent(input.id);
const existing = getLocalScout(input.id);
if (!existing) {
return { data: null, error: 'Agent not found' };
return { data: null, error: 'Scout not found' };
}
const updated: LocalAgentLocalConfig = {
const updated: LocalScoutConfig = {
...existing,
...(input.name !== undefined && { name: input.name }),
...(input.directory !== undefined && { directory: input.directory }),
@@ -1123,22 +1140,22 @@ const agentLocalRouter = router({
...(input.scheduleCron !== undefined && { scheduleCron: input.scheduleCron }),
...(input.enabled !== undefined && { enabled: input.enabled }),
};
saveLocalAgent(updated);
saveLocalScout(updated);
return { data: updated, error: null };
}),
delete: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(({ input }) => {
deleteLocalAgent(input.id);
deleteLocalScout(input.id);
return { success: true as const, error: null };
}),
});
const agentCloudRouter = router({
const scoutCloudRouter = router({
list: publicProcedure.query(async () => {
try {
return await getBackendClient().proxyGet<CloudAgentConfig[]>('/api/v1/agents/cloud');
return await getBackendClient().proxyGet<CloudScoutConfig[]>('/api/v1/scouts/cloud');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to list cloud agents';
console.error('[Agent] cloud.list error:', msg);
@@ -1150,20 +1167,24 @@ const agentCloudRouter = router({
.input(z.object({
name: z.string(),
provider: z.enum(['gmail', 'teams', 'outlook']),
dataTypes: z.array(z.string()),
promptTemplate: z.string(),
scheduleCron: z.string(),
filterConfig: z.record(z.string(), z.unknown()).optional(),
dataTypes: z.array(z.string()).default([]),
promptTemplate: z.string().default(''),
scheduleCron: z.string().optional(),
filterConfig: z.object({
labels: z.array(z.string()).optional(),
senders: z.array(z.string()).optional(),
}).optional(),
autoTrashSpam: z.boolean().optional(),
}))
.mutation(async ({ input }) => {
try {
const result = await getBackendClient().proxyPost<CloudAgentConfig>(
'/api/v1/agents/cloud',
const result = await getBackendClient().proxyPost<CloudScoutConfig>(
'/api/v1/scouts/cloud',
input as Record<string, unknown>,
);
return { data: result, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to create cloud agent';
const msg = err instanceof Error ? err.message : 'Failed to create cloud scout';
return { data: null, error: msg };
}
}),
@@ -1175,19 +1196,23 @@ const agentCloudRouter = router({
dataTypes: z.array(z.string()).optional(),
promptTemplate: z.string().optional(),
scheduleCron: z.string().optional(),
filterConfig: z.record(z.string(), z.unknown()).optional(),
filterConfig: z.object({
labels: z.array(z.string()).optional(),
senders: z.array(z.string()).optional(),
}).optional(),
autoTrashSpam: z.boolean().optional(),
enabled: z.boolean().optional(),
}))
.mutation(async ({ input }) => {
const { id, ...updates } = input;
try {
const result = await getBackendClient().proxyPut<CloudAgentConfig>(
`/api/v1/agents/cloud/${id}`,
const result = await getBackendClient().proxyPut<CloudScoutConfig>(
`/api/v1/scouts/cloud/${id}`,
updates as Record<string, unknown>,
);
return { data: result, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to update cloud agent';
const msg = err instanceof Error ? err.message : 'Failed to update cloud scout';
return { data: null, error: msg };
}
}),
@@ -1196,16 +1221,117 @@ const agentCloudRouter = router({
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
try {
await getBackendClient().proxyDelete<{ ok: boolean }>(`/api/v1/agents/cloud/${input.id}`);
await getBackendClient().proxyDelete<{ ok: boolean }>(`/api/v1/scouts/cloud/${input.id}`);
return { success: true as const, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to delete cloud agent';
return { success: false as const, error: msg };
}
}),
startGmailOAuth: publicProcedure
.input(z.object({ scoutId: z.string() }))
.mutation(async ({ input }) => {
const data = await getBackendClient().proxyGet<{ authorizeUrl: string }>(
`/api/v1/scouts/oauth/gmail/authorize?scout_id=${encodeURIComponent(input.scoutId)}`,
);
await shell.openExternal(data.authorizeUrl);
return { ok: true };
}),
// Creation-mode OAuth: no scout exists yet. The draft is held server-side in
// the pending OAuth session; the scout is created at finalizeCloudScout.
startGmailOAuthDraft: publicProcedure
.input(z.object({
name: z.string(),
promptTemplate: z.string(),
autoTrashSpam: z.boolean(),
}))
.mutation(async ({ input }) => {
// proxyPost snake_cases the body and camelCases the response.
const data = await getBackendClient().proxyPost<{ authorizeUrl: string }>(
'/api/v1/scouts/oauth/gmail/authorize-draft',
input as Record<string, unknown>,
);
await shell.openExternal(data.authorizeUrl);
return { ok: true };
}),
completeGmailOAuth: publicProcedure
.input(z.object({ code: z.string(), state: z.string() }))
.mutation(async ({ input }) => {
return await getBackendClient().proxyPost<{ ok: boolean; sessionId: string | null; gmailAddress: string | null }>(
'/api/v1/scouts/oauth/gmail/callback',
input as Record<string, unknown>,
);
}),
gmailLabels: publicProcedure
.input(z.object({ scoutId: z.string() }))
.query(async ({ input }) => {
try {
return await getBackendClient().proxyGet<{ id: string; name: string }[]>(
`/api/v1/scouts/cloud/${input.scoutId}/gmail-labels`,
);
} catch (err) {
console.error('[Scout] gmailLabels error:', err instanceof Error ? err.message : err);
return [];
}
}),
// Labels for a pending create-mode session (no scout row yet).
gmailSessionLabels: publicProcedure
.input(z.object({ session: z.string() }))
.query(async ({ input }) => {
try {
return await getBackendClient().proxyGet<{ id: string; name: string }[]>(
`/api/v1/scouts/oauth/gmail/session-labels?session=${encodeURIComponent(input.session)}`,
);
} catch (err) {
console.error('[Scout] gmailSessionLabels error:', err instanceof Error ? err.message : err);
return [];
}
}),
// Create the cloud scout at the end of the create-mode flow.
finalizeCloudScout: publicProcedure
.input(z.object({
session: z.string(),
filterConfig: z.object({
labels: z.array(z.string()).optional(),
senders: z.array(z.string()).optional(),
}).optional(),
}))
.mutation(async ({ input }) => {
try {
const result = await getBackendClient().proxyPost<CloudScoutConfig>(
'/api/v1/scouts/cloud/finalize',
input as Record<string, unknown>,
);
return { data: result, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to finalize cloud scout';
return { data: null, error: msg };
}
}),
disconnectGmail: publicProcedure
.input(z.object({ scoutId: z.string() }))
.mutation(async ({ input }) => {
try {
const result = await getBackendClient().proxyPost<CloudScoutConfig>(
`/api/v1/scouts/cloud/${input.scoutId}/gmail-disconnect`,
{},
);
return { data: result, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to disconnect Gmail';
return { data: null, error: msg };
}
}),
});
const agentJourneyRouter = router({
const scoutJourneyRouter = router({
start: publicProcedure
.input(z.object({
agentType: z.enum(['local_directory', 'gmail', 'teams', 'outlook']),
@@ -1243,20 +1369,20 @@ const agentJourneyRouter = router({
}),
});
const agentRouter = router({
/** Agent catalog — available agent types from the backend. */
const scoutRouter = router({
/** Scout catalog — available scout types from the backend. */
catalog: publicProcedure.query(async () => {
try {
return await getBackendClient().proxyGet<AgentCatalogItem[]>('/api/v1/agents/catalog');
return await getBackendClient().proxyGet<AgentCatalogItem[]>('/api/v1/scouts/catalog');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to load catalog';
console.error('[Agent] catalog error:', msg);
console.error('[Scout] catalog error:', msg);
return [];
}
}),
local: agentLocalRouter,
cloud: agentCloudRouter,
local: scoutLocalRouter,
cloud: scoutCloudRouter,
/** Run history — queries local SQLite (data written by backend-client on tool_call/run_complete). */
runs: publicProcedure
@@ -1272,18 +1398,18 @@ const agentRouter = router({
const offset = input.offset ?? 0;
const rows = await db
.select()
.from(agentRuns)
.where(eq(agentRuns.agentId, input.agentId))
.orderBy(desc(agentRuns.startedAt))
.from(scoutRuns)
.where(eq(scoutRuns.scoutId, input.agentId))
.orderBy(desc(scoutRuns.startedAt))
.limit(limit)
.offset(offset);
// Compute per-run action counts in one query
const runIds = rows.map(r => r.id);
const actionRows = runIds.length > 0
? await db.select({ runId: agentRunActions.runId, verb: agentRunActions.verb, entityType: agentRunActions.entityType })
.from(agentRunActions)
.where(inArray(agentRunActions.runId, runIds))
? await db.select({ runId: scoutRunActions.runId, verb: scoutRunActions.verb, entityType: scoutRunActions.entityType })
.from(scoutRunActions)
.where(inArray(scoutRunActions.runId, runIds))
: [];
type ActionCounts = { created: number; updated: number; deleted: number };
@@ -1302,7 +1428,7 @@ const agentRouter = router({
}));
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to load run history';
console.error('[Agent] runs error:', msg);
console.error('[Scout] runs error:', msg);
return [];
}
}),
@@ -1315,60 +1441,60 @@ const agentRouter = router({
const db = getDb();
return await db
.select()
.from(agentRunActions)
.where(eq(agentRunActions.runId, input.runId))
.orderBy(asc(agentRunActions.createdAt));
.from(scoutRunActions)
.where(eq(scoutRunActions.runId, input.runId))
.orderBy(asc(scoutRunActions.createdAt));
} catch (err) {
console.error('[Agent] runActions error:', err);
console.error('[Scout] runActions error:', err);
return [];
}
}),
/** Check whether the user's plan allows creating a new agent. */
/** Check whether the user's plan allows creating a new scout. */
canCreate: publicProcedure.mutation(async () => {
try {
const activeAgents = getLocalAgents().length;
const activeScouts = getLocalScouts().length;
const result = await getBackendClient().proxyPost<{ allowed: boolean; tier: string; activeAgents: number; limit: number }>(
'/api/v1/agents/can-create',
{ activeAgents },
'/api/v1/scouts/can-create',
{ activeAgents: activeScouts },
);
return { data: result, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to check agent quota';
const msg = err instanceof Error ? err.message : 'Failed to check scout quota';
return { data: null, error: msg };
}
}),
/** Manually trigger a local agent run via the BE two-phase runner. */
/** Manually trigger a local scout run via the BE two-phase runner. */
runNow: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
try {
const agent = getLocalAgent(input.id);
if (!agent) return { data: null, error: 'Agent not found' };
const activeAgents = getLocalAgents().length;
const scout = getLocalScout(input.id);
if (!scout) return { data: null, error: 'Scout not found' };
const activeScouts = getLocalScouts().length;
console.log(
`[agents.runNow] Triggering agent "${agent.name}" (id=${agent.id}) with lastRunAt=${agent.lastRunAt} (${agent.lastRunAt ? new Date(agent.lastRunAt).toISOString() : 'null'})`,
`[scout.runNow] Triggering scout "${scout.name}" (id=${scout.id}) with lastRunAt=${scout.lastRunAt} (${scout.lastRunAt ? new Date(scout.lastRunAt).toISOString() : 'null'})`,
);
const result = await getBackendClient().proxyPost<{ id: string }>(
'/api/v1/agents/trigger',
'/api/v1/scouts/trigger',
{
directory: agent.directory,
directory: scout.directory,
deviceId: getDeviceId(),
agentId: agent.id,
whatToExtract: agent.dataTypes,
batchInterval: agent.scheduleCron,
agentConfig: agent.agentConfig ?? undefined,
activeAgents,
lastRunAt: agent.lastRunAt ?? undefined,
agentId: scout.id,
whatToExtract: scout.dataTypes,
batchInterval: scout.scheduleCron,
agentConfig: scout.agentConfig ?? undefined,
activeAgents: activeScouts,
lastRunAt: scout.lastRunAt ?? undefined,
},
);
// Create the run row so it appears in history even with zero mutations
if (result?.id) {
try {
await getDb().insert(agentRuns).values({
await getDb().insert(scoutRuns).values({
id: result.id,
agentId: agent.id,
scoutId: scout.id,
status: 'running',
startedAt: Date.now(),
}).onConflictDoNothing();
@@ -1376,12 +1502,12 @@ const agentRouter = router({
}
return { data: result, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to trigger agent run';
const msg = err instanceof Error ? err.message : 'Failed to trigger scout run';
return { data: null, error: msg };
}
}),
journey: agentJourneyRouter,
journey: scoutJourneyRouter,
});
// ---------------------------------------------------------------------------
@@ -1739,6 +1865,85 @@ const noteEditsRouter = router({
}),
});
const taskAttachmentsRouter = router({
list: publicProcedure
.input(z.object({ taskId: z.string() }))
.query(({ input }) => {
return getDb()
.select()
.from(taskAttachments)
.where(eq(taskAttachments.taskId, input.taskId))
.orderBy(asc(taskAttachments.createdAt))
.all();
}),
pick: publicProcedure.mutation(async () => {
const result = await dialog.showOpenDialog({
properties: ['openFile', 'multiSelections'],
});
if (result.canceled) return [];
const out: Array<{ path: string; name: string; size: number }> = [];
for (const p of result.filePaths) {
const s = await stat(p);
out.push({ path: p, name: p.split(/[\\/]/).pop() ?? p, size: s.size });
}
return out;
}),
create: publicProcedure
.input(z.object({
taskId: z.string(),
sourcePath: z.string(),
filename: z.string(),
sizeBytes: z.number().int().nonnegative(),
mimeType: z.string().optional(),
}))
.mutation(async ({ input }) => {
const db = getDb();
const { storedPath } = await copyIntoTask(input.taskId, input.sourcePath, input.filename);
const row = {
id: randomUUID(),
taskId: input.taskId,
filename: input.filename,
mimeType: input.mimeType ?? null,
sizeBytes: input.sizeBytes,
storedPath,
createdAt: Date.now(),
};
db.insert(taskAttachments).values(row).run();
return row;
}),
delete: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
const db = getDb();
const row = db
.select()
.from(taskAttachments)
.where(eq(taskAttachments.id, input.id))
.get();
if (!row) return { ok: false };
await deleteStored(row.storedPath);
db.delete(taskAttachments).where(eq(taskAttachments.id, input.id)).run();
return { ok: true };
}),
open: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
const db = getDb();
const row = db
.select()
.from(taskAttachments)
.where(eq(taskAttachments.id, input.id))
.get();
if (!row) return { ok: false };
const err = await shell.openPath(absolutePath(row.storedPath));
return { ok: err === '' };
}),
});
export const appRouter = router({
health: healthRouter,
settings: settingsRouter,
@@ -1750,10 +1955,13 @@ export const appRouter = router({
notes: notesRouter,
noteEdits: noteEditsRouter,
taskComments: taskCommentsRouter,
taskAttachments: taskAttachmentsRouter,
ai: aiRouter,
auth: authRouter,
agent: agentRouter,
scout: scoutRouter,
memory: memoryRouter,
projectFolders: projectFoldersRouter,
aiChat: aiChatRouter,
});
export type AppRouter = typeof appRouter;

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

@@ -1,21 +1,21 @@
/**
* Agent scheduler checks locally-stored agent configs on a periodic
* 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 agents.
* a single `setInterval` tick that checks all enabled scouts.
*/
import { getLocalAgents, saveLocalAgent, getDeviceId } from '../store';
import { getLocalScouts, saveLocalScout, getDeviceId } from '../store';
import { getBackendClient } from '../api/backend-client';
import { getDb } from '../db';
import { agentRuns } from '../db/schema';
import { scoutRuns } from '../db/schema';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** How often the scheduler checks for due agents (ms). */
/** How often the scheduler checks for due scouts (ms). */
const TICK_INTERVAL_MS = 60_000; // 60 seconds
/**
@@ -40,18 +40,18 @@ let schedulerTimer: ReturnType<typeof setInterval> | null = null;
// Public API
// ---------------------------------------------------------------------------
export function startAgentScheduler(): void {
export function startScoutScheduler(): void {
if (schedulerTimer) return;
schedulerTimer = setInterval(() => {
void tickAgentScheduler();
void tickScoutScheduler();
}, TICK_INTERVAL_MS);
// Run once immediately on start
void tickAgentScheduler();
void tickScoutScheduler();
}
export function stopAgentScheduler(): void {
export function stopScoutScheduler(): void {
if (schedulerTimer) {
clearInterval(schedulerTimer);
schedulerTimer = null;
@@ -62,46 +62,46 @@ export function stopAgentScheduler(): void {
// Tick
// ---------------------------------------------------------------------------
async function tickAgentScheduler(): Promise<void> {
const agents = getLocalAgents();
async function tickScoutScheduler(): Promise<void> {
const scouts = getLocalScouts();
const now = Date.now();
for (const agent of agents) {
if (!agent.enabled) continue;
for (const scout of scouts) {
if (!scout.enabled) continue;
// Manual-only agents don't auto-trigger
const intervalMs = CRON_INTERVAL_MS[agent.scheduleCron];
// 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 (agent.lastRunAt && now - agent.lastRunAt < intervalMs) continue;
if (scout.lastRunAt && now - scout.lastRunAt < intervalMs) continue;
try {
const activeAgents = agents.length;
const activeScouts = scouts.length;
console.log(
`[AgentScheduler] Triggering agent "${agent.name}" (id=${agent.id}) with lastRunAt=${agent.lastRunAt} (${agent.lastRunAt ? new Date(agent.lastRunAt).toISOString() : 'null'})`,
`[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/agents/trigger',
'/api/v1/scouts/trigger',
{
directory: agent.directory,
directory: scout.directory,
deviceId: getDeviceId(),
agentId: agent.id,
whatToExtract: agent.dataTypes,
batchInterval: agent.scheduleCron,
agentConfig: agent.agentConfig ?? undefined,
activeAgents,
lastRunAt: agent.lastRunAt ?? undefined,
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 agent finds nothing to create/update.
// the scout finds nothing to create/update.
if (response?.id) {
try {
await getDb().insert(agentRuns).values({
await getDb().insert(scoutRuns).values({
id: response.id,
agentId: agent.id,
scoutId: scout.id,
status: 'running',
startedAt: now,
}).onConflictDoNothing();
@@ -109,11 +109,11 @@ async function tickAgentScheduler(): Promise<void> {
}
// Mark the run time so we don't re-trigger until the next interval
saveLocalAgent({ ...agent, lastRunAt: now });
console.log(`[AgentScheduler] Triggered agent "${agent.name}" (id=${agent.id}).`);
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(`[AgentScheduler] Failed to trigger agent "${agent.name}": ${msg}`);
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,10 +1,10 @@
import Store from 'electron-store';
// ---------------------------------------------------------------------------
// Local agent config — stored entirely on the FE, never on the backend.
// Local scout config — stored entirely on the FE, never on the backend.
// ---------------------------------------------------------------------------
export interface LocalAgentLocalConfig {
export interface LocalScoutConfig {
id: string;
name: string;
directory: string;
@@ -43,8 +43,8 @@ interface AppSettings {
deviceId: string;
/** Cached daily brief — regenerated once per day or when relevant data changes. */
dailyBriefCache: { content: string; date: string } | null;
/** Locally-managed agent configurations. */
localAgents: LocalAgentLocalConfig[];
/** Locally-managed scout configurations. */
localScouts: LocalScoutConfig[];
/** OS-detected display format preferences. */
formatPrefs: FormatPrefs | null;
/** UI language code (e.g. 'en', 'it', 'es', 'fr', 'de'). */
@@ -66,7 +66,7 @@ export function getStore(): Store<AppSettings> {
backendUrl: 'http://localhost:8000',
deviceId: '',
dailyBriefCache: null,
localAgents: [],
localScouts: [],
formatPrefs: null,
uiLanguage: 'en',
timelineZoom: 'day',
@@ -91,31 +91,31 @@ export function getDeviceId(): string {
}
// ---------------------------------------------------------------------------
// Local agent helpers
// Local scout helpers
// ---------------------------------------------------------------------------
export function getLocalAgents(): LocalAgentLocalConfig[] {
return getStore().get('localAgents');
export function getLocalScouts(): LocalScoutConfig[] {
return getStore().get('localScouts');
}
export function getLocalAgent(id: string): LocalAgentLocalConfig | undefined {
return getLocalAgents().find((a) => a.id === id);
export function getLocalScout(id: string): LocalScoutConfig | undefined {
return getLocalScouts().find((s) => s.id === id);
}
export function saveLocalAgent(agent: LocalAgentLocalConfig): void {
const agents = getLocalAgents();
const idx = agents.findIndex((a) => a.id === agent.id);
export function saveLocalScout(scout: LocalScoutConfig): void {
const scouts = getLocalScouts();
const idx = scouts.findIndex((s) => s.id === scout.id);
if (idx >= 0) {
agents[idx] = agent;
scouts[idx] = scout;
} else {
agents.push(agent);
scouts.push(scout);
}
getStore().set('localAgents', agents);
getStore().set('localScouts', scouts);
}
export function deleteLocalAgent(id: string): void {
const agents = getLocalAgents().filter((a) => a.id !== id);
getStore().set('localAgents', agents);
export function deleteLocalScout(id: string): void {
const scouts = getLocalScouts().filter((s) => s.id !== id);
getStore().set('localScouts', scouts);
}
// ---------------------------------------------------------------------------

View File

@@ -25,21 +25,7 @@ const AI_STREAM_CHANNEL = 'ai:stream';
type V3StreamEvent =
| { type: 'stream_start'; requestId: string }
| { type: 'stream_text'; requestId: string; chunk: string }
| { type: 'stream_end'; requestId: string; mutations?: unknown[] }
| {
type: 'floating_domain';
requestId: string;
domain:
| 'tasks'
| 'notes'
| 'timelines'
| 'projects'
| {
type: 'task' | 'timeline' | 'project' | 'note' | 'node';
id?: string | null;
section?: 'task' | 'timeline' | 'note' | null;
};
};
| { type: 'stream_end'; requestId: string; mutations?: unknown[] };
contextBridge.exposeInMainWorld('electronAI', {
/** Subscribe to v3 AI stream events. Returns an unsubscribe function. */
@@ -58,6 +44,17 @@ contextBridge.exposeInMainWorld('electronAI', {
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);
};
},
});
// ---------------------------------------------------------------------------

View File

@@ -1,20 +1,27 @@
import { useState } from 'react';
import {
CheckCircle2,
XCircle,
AlertCircle,
Loader2,
ChevronDown,
ChevronRight,
Clock,
FileCheck,
FilePlus,
} 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';
import type { AgentRunLog } from '../../../shared/api-types';
// ---------------------------------------------------------------------------
// 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
@@ -22,16 +29,16 @@ import type { AgentRunLog } from '../../../shared/api-types';
function statusBadge(status: string) {
switch (status) {
case 'success':
case 'completed':
return (
<Badge variant="secondary" className="gap-1 text-emerald-600 dark:text-emerald-400 shrink-0">
<CheckCircle2 className="size-3" /> Success
<CheckCircle2 className="size-3" /> Done
</Badge>
);
case 'error':
case 'failed':
return (
<Badge variant="destructive" className="gap-1 shrink-0">
<XCircle className="size-3" /> Error
<XCircle className="size-3" /> Failed
</Badge>
);
case 'running':
@@ -55,11 +62,10 @@ function statusBadge(status: string) {
// Per-run row
// ---------------------------------------------------------------------------
function RunRow({ run }: { run: AgentRunLog }) {
function RunRow({ run }: { run: ScoutRunSummary }) {
const prefs = useFormatPrefs();
const [errorsOpen, setErrorsOpen] = useState(false);
const hasErrors = (run.errors ?? []).length > 0;
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">
@@ -75,44 +81,20 @@ function RunRow({ run }: { run: AgentRunLog }) {
</span>
)}
<span className="flex items-center gap-1 text-muted-foreground shrink-0">
<FileCheck className="size-3" />
{run.itemsProcessed} processed
<span className="text-muted-foreground shrink-0">
{totalActions} action{totalActions !== 1 ? 's' : ''}
</span>
<span className="flex items-center gap-1 text-muted-foreground shrink-0">
<FilePlus className="size-3" />
{run.itemsCreated} created
</span>
{hasErrors && (
<button
onClick={() => setErrorsOpen(v => !v)}
className="ml-auto flex items-center gap-1 text-destructive hover:text-destructive/80 transition-colors"
>
{errorsOpen ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
{run.errors.length} {run.errors.length === 1 ? 'error' : 'errors'}
</button>
)}
</div>
{hasErrors && errorsOpen && (
<div className="border-t px-3 py-2 flex flex-col gap-1">
{run.errors.map((err, i) => (
<p key={i} className="text-xs text-destructive font-mono break-all">{err}</p>
))}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// AgentRunLog
// ScoutRunLog
// ---------------------------------------------------------------------------
export function AgentRunLog({ agentId, expanded }: { agentId: string; expanded: boolean }) {
const runsQuery = trpc.agent.runs.useQuery(
export function ScoutRunLog({ agentId, expanded }: { agentId: string; expanded: boolean }) {
const runsQuery = trpc.scout.runs.useQuery(
{ agentId, limit: 10 },
{ enabled: expanded },
);
@@ -139,7 +121,7 @@ export function AgentRunLog({ agentId, expanded }: { agentId: string; expanded:
{!runsQuery.isPending && (runsQuery.data ?? []).length > 0 && (
<div className="flex flex-col gap-2">
{(runsQuery.data as AgentRunLog[]).map(run => (
{(runsQuery.data as ScoutRunSummary[]).map(run => (
<RunRow key={run.id} run={run} />
))}
</div>

View File

@@ -1,8 +1,6 @@
import { useState, useEffect, useRef, useCallback, useMemo, forwardRef, memo } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Link } from '@tanstack/react-router';
import { Sparkles, LogIn, ListTodo, TrendingUp, AlertCircle, Lightbulb, ChevronDown, ChevronUp, X } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
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';
@@ -14,148 +12,11 @@ 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 { ChatEntityBlock } from './blocks/ChatEntityBlock';
import { ChatChartBlock } from './blocks/ChatChartBlock';
import type { EntityRefBlockData, ChartBlockData } from '../../../shared/api-types';
import { ChatSurface, MessageContent, ChatMarkdown } from './ChatSurface';
/** Fluid font size for chat messages — scales with viewport width */
// const CHAT_FONT = '1rem';
// ---------------------------------------------------------------------------
// Inline tag parsing (entities + charts)
// ---------------------------------------------------------------------------
/**
* Matches entity tags in both formats:
* - <task>[id1,id2]</task>
* - <timeline>id1,id2</timeline>
*/
const ENTITY_TAG_RE = /<(?<entity>task|project|note|timeline|timelineEvent)>(?:\[(?<bracketIds>[^\]]+)\]|(?<plainIds>[^<]+))<\/\k<entity>>/;
/** Matches chart tags: <chart>{...JSON...}</chart> */
const CHART_TAG_RE = /<chart>(?<chartJson>\{[\s\S]*?\})<\/chart>/;
/** Combined: matches the first occurrence of either tag */
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 {
// Malformed JSON — keep as text
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);
}
// Keep prose flow untouched and place consolidated timeline at the last timeline tag position.
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;
// Merge only adjacent task tags, allowing whitespace-only text between them.
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;
}
const SUGGESTION_CHIPS = [
{ icon: ListTodo, labelKey: 'home.chipWhatsOnMyPlate' },
@@ -219,6 +80,46 @@ export function AIChatPanel({
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 }),
[],
@@ -231,6 +132,28 @@ export function AIChatPanel({
clearMessages,
cacheKey,
} = useAIChat(chatContext);
// 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
@@ -264,6 +187,13 @@ export function AIChatPanel({
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.
});
},
};
}
@@ -602,74 +532,19 @@ export function AIChatPanel({
</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">
<div className="flex flex-col gap-4">
<div
aria-hidden
style={{
height: '4vw',
flexShrink: 0,
}}
/>
{/* Chat messages */}
{messages.map((msg, idx) => {
const isLast = idx === messages.length - 1;
// The last user message gets a ref for scroll targeting.
// The last assistant message (when not streaming) gets the
// minHeight so it fills remaining viewport space.
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}
/>
);
})}
{/* Streaming AI response — minHeight fills remaining viewport space */}
{isStreaming && (
<AIMessage
ref={lastAiRef}
content={streamingContent}
bottomPad
minHeight={aiMinHeight}
skeleton={!streamingContent}
/>
)}
</div>
</div>
<ChatSurface
variant="home"
messages={messages}
streamingContent={streamingContent}
isStreaming={isStreaming}
onSend={handleSend}
cacheKey={cacheKey}
aiMinHeight={aiMinHeight}
lastUserMsgRef={lastUserMsgRef}
lastAiRef={lastAiRef}
/>
)}
{/* Non-home messages */}
@@ -696,121 +571,6 @@ export function AIChatPanel({
);
}
/* ---------- AIMessage: shared layout for completed + streaming AI turns ---------- */
interface AIMessageProps {
content: string;
bottomPad?: boolean;
minHeight?: number | null;
skeleton?: boolean;
}
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';
/* ---------- MessageContent: text with inline entity blocks ---------- */
const MessageContent = memo(function MessageContent({ content, fontSize }: { content: string; fontSize?: string }) {
const segments = useMemo(
() => mergeConsecutiveTaskSegments(mergeTimelineSegments(parseInlineTags(content))),
[content],
);
// Fast path: no inline tags, just render markdown
if (segments.length === 1 && segments[0]?.type === 'text') {
return <ChatMarkdown content={content} fontSize={fontSize} />;
}
// No content at all
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>
);
});
const blockAnimation = {
initial: { opacity: 0, scale: 0.95 },
animate: { opacity: 1, scale: 1 },
transition: { type: 'spring' as const, stiffness: 400, damping: 30 },
};
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
// Stable references — defined outside the component so react-markdown never
// sees a changed prop reference and re-parses content on every render.
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>
);
}
// 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

@@ -9,7 +9,7 @@ export interface ChatInputBoxHandle {
focus: () => void;
}
type ChatInputBoxVariant = 'panel' | 'floating';
type ChatInputBoxVariant = 'panel' | 'comment';
interface ChatInputBoxProps {
cacheKey: string;
@@ -27,10 +27,10 @@ const VARIANT_STYLES = {
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,
},
floating: {
container: 'flex items-center gap-2 px-3 py-2.5',
textarea: 'flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground/60 outline-none max-h-20 overflow-y-auto',
button: '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',
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;
@@ -43,7 +43,7 @@ export const ChatInputBox = forwardRef<ChatInputBoxHandle, ChatInputBoxProps>(
const valueRef = useRef(value);
valueRef.current = value;
// Re-init when the cache key changes (context switches in FloatingChat).
// Re-init when the cache key changes (context switches).
const prevKeyRef = useRef(cacheKey);
useEffect(() => {
if (prevKeyRef.current !== cacheKey) {

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,438 +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 } from 'lucide-react';
import {
useFloatingChat,
computeDualAnchor,
getChatWidth,
CHAT_HEIGHT,
PADDING,
} from '@/context/FloatingChatContext';
import { useAIChat, type UIChatContext, type FloatingDomainSignal } from '@/hooks/useAIChat';
import { ChatMarkdown } from '@/components/ai/AIChatPanel';
import { ChatInputBox, type ChatInputBoxHandle } from '@/components/ai/ChatInputBox';
import { Skeleton } from '@/components/ui/skeleton';
/** Map floating_domain signals to routes for background navigation */
const DOMAIN_ROUTES: Record<string, string> = {
tasks: '/tasks',
notes: '/notes',
timelines: '/timeline',
projects: '/projects',
};
const DOMAIN_SECTION_IDS: Partial<Record<'tasks' | 'notes' | 'timelines' | 'projects', string>> = {
tasks: 'tasks-list',
timelines: 'timeline-chart',
};
interface DomainNavigationTarget {
route: '/tasks' | '/timeline' | '/projects' | '/notes/$noteId';
sectionId?: string;
projectId?: string;
noteId?: string;
nodeId?: string;
}
function normalizeDomainSignal(domain: FloatingDomainSignal): DomainNavigationTarget | null {
if (typeof domain === 'string') {
const route = DOMAIN_ROUTES[domain];
if (!route) return null;
return {
route: route as DomainNavigationTarget['route'],
sectionId: DOMAIN_SECTION_IDS[domain as keyof typeof DOMAIN_SECTION_IDS],
};
}
switch (domain.type) {
case 'task':
return { route: '/tasks', sectionId: 'tasks-list' };
case 'timeline':
return { route: '/timeline', sectionId: 'timeline-chart' };
case 'note':
if (!domain.id) return { route: '/projects' };
return { route: '/notes/$noteId', noteId: domain.id };
case 'project': {
if (domain.section === 'task') {
return { route: '/projects', sectionId: 'project-tasks', projectId: domain.id ?? undefined };
}
if (domain.section === 'timeline') {
return { route: '/projects', sectionId: 'project-timeline', projectId: domain.id ?? undefined };
}
if (domain.section === 'note') {
return { route: '/projects', sectionId: 'project-notes', projectId: domain.id ?? undefined };
}
return { route: '/projects', projectId: domain.id ?? undefined };
}
case 'node':
if (!domain.id) return null;
return { route: '/projects', sectionId: domain.id, nodeId: domain.id };
default:
return null;
}
}
function FloatingChatInner() {
const { state, sections, close, updatePosition, setPendingSection, moveToSection } = useFloatingChat();
const navigate = useNavigate();
const routerState = useRouterState();
const prevPathRef = useRef(routerState.location.pathname);
const domainNavigationInFlightRef = useRef(false);
// Active section lookup
const activeSection = sections.get(state.activeSectionId ?? '');
// Chat context — floating mode with scope derived from active section
const chatContext = useMemo<UIChatContext>(() => {
const scope = activeSection
? {
type: (activeSection.label?.toLowerCase().includes('task')
? 'task'
: activeSection.label?.toLowerCase().includes('note')
? 'note'
: activeSection.label?.toLowerCase().includes('timeline')
? 'timeline'
: 'project') as 'task' | 'project' | 'note' | 'timeline',
id: activeSection.projectId,
}
: undefined;
return {
type: 'floating' as const,
projectId: activeSection?.projectId,
scope,
};
}, [activeSection?.projectId, activeSection?.label]);
// Handle floating_domain signals — navigate in background
const handleDomainSignal = useCallback(
(domainSignal: FloatingDomainSignal) => {
const target = normalizeDomainSignal(domainSignal);
if (!target) return;
// If backend points to a currently registered node/section, move there immediately.
if (target.sectionId && sections.has(target.sectionId)) {
moveToSection(target.sectionId);
return;
}
const currentPath = routerState.location.pathname;
const isCurrentRoute =
(target.route === '/projects' && currentPath === '/projects') ||
(target.route === '/tasks' && currentPath === '/tasks') ||
(target.route === '/timeline' && currentPath === '/timeline') ||
(target.route === '/notes/$noteId' && currentPath.startsWith('/notes/'));
if (isCurrentRoute && target.sectionId) {
setPendingSection({ sectionId: target.sectionId });
return;
}
if (isCurrentRoute) return;
domainNavigationInFlightRef.current = true;
const pendingSectionId = target.sectionId;
if (pendingSectionId) {
setPendingSection({ sectionId: pendingSectionId });
} else {
setPendingSection(undefined);
}
if (target.route === '/projects') {
void navigate({ to: '/projects', search: target.projectId ? { projectId: target.projectId } : {} });
} else if (target.route === '/notes/$noteId' && target.noteId) {
void navigate({ to: '/notes/$noteId', params: { noteId: target.noteId } });
} else if (target.route === '/tasks') {
void navigate({ to: '/tasks' });
} else if (target.route === '/timeline') {
void navigate({ to: '/timeline' });
}
},
[routerState.location.pathname, navigate, setPendingSection, sections, moveToSection],
);
const {
messages,
isStreaming,
streamingContent,
handleSend,
clearMessages,
cacheKey,
} = useAIChat(chatContext, { onDomainSignal: handleDomainSignal });
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) ----
// Tracks whether the most recent close was triggered by user navigation.
// Used to decide whether to reset the session on close.
const closeByNavigationRef = useRef(false);
useEffect(() => {
const currentPath = routerState.location.pathname;
if (prevPathRef.current !== currentPath && state.isOpen) {
// Keep floating chat alive when navigation is AI-domain driven.
if (domainNavigationInFlightRef.current) {
domainNavigationInFlightRef.current = false;
} else if (!state.pendingSection) {
closeByNavigationRef.current = true;
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) {
const resetSession = closeByNavigationRef.current;
closeByNavigationRef.current = false;
// Clear input draft first so the unmount flush writes '' to the cache.
inputRef.current?.clear();
clearMessages(resetSession);
}
prevOpenRef.current = state.isOpen;
}, [state.isOpen, clearMessages]);
// ---- 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<ChatInputBoxHandle>(null);
useEffect(() => {
if (state.isOpen) {
const timer = setTimeout(() => inputRef.current?.focus(), 100);
return () => clearTimeout(timer);
}
}, [state.isOpen]);
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>
<ChatInputBox
ref={inputRef}
variant="floating"
cacheKey={cacheKey}
isStreaming={isStreaming}
onSend={handleSend}
placeholder={`Ask about ${activeSection?.label ?? 'this section'}...`}
/>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
export function FloatingChatPortal() {
return createPortal(<FloatingChatInner />, document.body);
}

View File

@@ -3,8 +3,9 @@ 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, type TaskItem } from '@/components/tasks/TaskRow';
import { TaskDetailDialog } from '@/components/tasks/TaskDetailDialog';
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';
@@ -84,7 +85,7 @@ function TaskEntityBlock({ ids }: { ids: string[] }) {
))}
</EntityWrapper>
<TaskDetailDialog
<TaskDetailSheet
task={viewTask}
open={!!viewTask}
onOpenChange={(open) => { if (!open) setViewTask(null); }}

View File

@@ -177,7 +177,7 @@ export function TaskBriefChat({ taskId, projectId, initialBriefing, onBriefingRe
message: trimmed,
conversationHistory,
sessionId,
mode: 'floating',
mode: 'contextual',
scope: { type: 'task', id: taskId },
briefMode: true,
briefingContext: briefingText || undefined,

View File

@@ -1,6 +1,12 @@
import { useState, useRef, useMemo } from 'react';
import { Link, useRouterState, useNavigate } 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,
@@ -19,7 +25,6 @@ import {
import { cn } from '@/lib/utils';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { useDoubleClickAI } from '@/hooks/useDoubleClickAI';
import { useTheme } from '@/components/theme-provider';
import {
Sidebar,
@@ -61,8 +66,6 @@ import {
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 { FloatingChatProvider } from '@/context/FloatingChatContext';
import { ExpandedClientsProvider, useExpandedClients } from '@/context/ExpandedClientsContext';
import { TaskBriefingProvider, useTaskBriefing } from '@/context/TaskBriefingContext';
import { LoginForm } from '@/components/auth/LoginForm';
@@ -76,26 +79,85 @@ const NAV_ITEMS = [
{ 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>
<ExpandedClientsProvider>
<TaskBriefingProvider>
<ExpandedClientsProvider>
<TaskBriefingProvider>
<HeaderProvider>
<div className="flex w-full h-full">
<AppShellInner>{children}</AppShellInner>
</div>
</TaskBriefingProvider>
</ExpandedClientsProvider>
</FloatingChatProvider>
</HeaderProvider>
</TaskBriefingProvider>
</ExpandedClientsProvider>
);
}
function AppShellInner({ children }: AppShellProps) {
useDoubleClickAI();
const { t } = useTranslation();
const authStatusQuery = trpc.auth.status.useQuery(undefined, {
@@ -125,18 +187,22 @@ function AppShellInner({ children }: AppShellProps) {
const [homeChatHasMessages, setHomeChatHasMessages] = useState(false);
const isHomePage = currentPath === '/';
const isProjectsPage = currentPath.startsWith('/projects');
const isNotesPage = currentPath.startsWith('/notes');
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 pageLabel = matchedItem ? t(matchedItem.labelKey) : (currentPath.startsWith('/settings') ? t('nav.settings') : '');
const routeLabel = matchedItem ? t(matchedItem.labelKey) : (currentPath.startsWith('/settings') ? t('nav.settings') : '');
// Pages with their own header (SidebarTrigger integrated) hide the global one
const showHeader = !isProjectsPage && !isNotesPage && !isSettingsPage && !isHomePage;
// 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 />;
@@ -157,26 +223,6 @@ function AppShellInner({ children }: AppShellProps) {
profile={authStatusQuery.data?.profile ?? null}
/>
<SidebarInset className="min-w-0 min-h-0 overflow-x-hidden">
{showHeader && (
<header className="flex h-14 shrink-0 items-center gap-2">
<div className="flex flex-1 items-center gap-2 px-3">
<SidebarTrigger />
{!isHomePage && (
<>
<Separator orientation="vertical" className="data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px mr-2 data-[orientation=vertical]:h-4" />
{/* <Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage>{pageLabel}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb> */}
<h4 className="text-sm font-medium text-foreground flex-1">{pageLabel}</h4>
</>
)}
</div>
</header>
)}
{isHomePage ? (
<div className="relative flex-1 min-h-0">
{!taskBriefing.isOpen && (
@@ -199,12 +245,40 @@ function AppShellInner({ children }: AppShellProps) {
<AIChatPanel isHomePage actionsRef={chatActionsRef} onHasMessagesChange={setHomeChatHasMessages} />
</div>
) : (
children
<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>
<FloatingChatPortal />
</LayoutGroup>
);
}

View File

@@ -1,177 +0,0 @@
import { memo, useState, useMemo, useCallback } from 'react';
import { DragDropContext, Droppable, Draggable, type DropResult } from '@hello-pangea/dnd';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
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';
import { useTranslation } from 'react-i18next';
const COLUMNS = [
{ id: 'todo', labelKey: 'tasks.toDo' },
{ id: 'in_progress', labelKey: 'tasks.inProgress' },
{ id: 'done', labelKey: 'tasks.completed' },
] as const;
type ColumnId = (typeof COLUMNS)[number]['id'];
type KanbanBoardProps = {
projectId: string;
newTaskOpen: boolean;
onNewTaskOpenChange: (open: boolean) => void;
};
function KanbanBoardInner({ projectId, newTaskOpen, onNewTaskOpenChange }: KanbanBoardProps) {
const { t } = useTranslation();
const { state: floatingState } = useFloatingChat();
const { data: tasksList } = trpc.tasks.list.useQuery({ projectId });
const utils = trpc.useUtils();
const { notify, notifyError } = useNotify();
const updateTask = trpc.tasks.update.useMutation({
onSuccess: () => 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.list.invalidate();
},
onError: (err) => notifyError('toast.task.deleteError', err),
});
// Edit / view task dialog state
const [editTask, setEditTask] = useState<TaskItem | null>(null);
const [viewTask, setViewTask] = useState<TaskItem | null>(null);
const columns = useMemo(() => {
const tasks = tasksList ?? [];
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">{t(col.labelKey)}</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); }}
/>
</>
);
}
export const KanbanBoard = memo(KanbanBoardInner);

View File

@@ -14,18 +14,20 @@ import {
BreadcrumbList,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import { KanbanBoard } from './KanbanBoard';
import { TaskListView } from '@/components/tasks/TaskListView';
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
import { type TimelineEvent } from '@/components/timeline/ProjectTimeline';
import { type ProjectGroup } from '@/components/timeline/ProjectTimelineBox';
import { TimelineGanttView } from '@/components/timeline/TimelineGanttView';
import { AddEventDialog } from '@/components/timeline/AddEventDialog';
import { EditEventDialog } from '@/components/timeline/EditEventDialog';
import { useFloatingChat } from '@/context/FloatingChatContext';
import { useTimelineHistory } from '@/hooks/useTimelineHistory';
import type { EventSnapshot } from '@/components/timeline/history-types';
import { cn } from '@/lib/utils';
import { ProjectTabBar, SECTIONS, type SectionId } from './ProjectTabBar';
import { FolderChip } from './folder/FolderChip';
import { FilesSection } from './folder/FilesSection';
import { useContextualScope } from '@/hooks/useContextualScope';
type ProjectDetailProps = {
projectId: string;
@@ -36,7 +38,6 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
const { t } = useTranslation();
const prefs = useFormatPrefs();
const navigate = useNavigate();
const [newTaskOpen, setNewTaskOpen] = useState(false);
const [addEventOpen, setAddEventOpen] = useState(false);
const [editingEvent, setEditingEvent] = useState<TimelineEvent | null>(null);
const [compact, setCompact] = useState(false);
@@ -47,18 +48,24 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
const timelineRef = useRef<HTMLDivElement>(null);
const tasksRef = useRef<HTMLDivElement>(null);
const notesRef = useRef<HTMLDivElement>(null);
const filesRef = useRef<HTMLDivElement>(null);
const sectionRefs: Record<SectionId, React.RefObject<HTMLDivElement | null>> = useMemo(() => ({
overview: summaryRef,
timeline: timelineRef,
tasks: tasksRef,
notes: notesRef,
files: filesRef,
}), []);
const didInitialScroll = useRef(false);
const { registerSection, unregisterSection } = useFloatingChat();
const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
const { data: scanStatus } = trpc.projectFolders.getStatus.useQuery(
{ projectId },
{ refetchInterval: (query) => query.state.data?.status === 'scanning' ? 1000 : false },
);
const {
historyOpRef,
pendingCreatePayloadRef,
@@ -79,18 +86,6 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
const clearHistoryRef = useRef(clearHistory);
clearHistoryRef.current = clearHistory;
useEffect(() => {
if (isLoading || !project) return;
registerSection({ id: 'project-summary', label: 'Project Summary', ref: summaryRef, projectId });
registerSection({ id: 'project-tasks', label: 'Tasks', ref: tasksRef, projectId });
registerSection({ id: 'project-notes', label: 'Notes', ref: notesRef, projectId });
return () => {
unregisterSection('project-summary');
unregisterSection('project-tasks');
unregisterSection('project-notes');
};
}, [projectId, isLoading, project, registerSection, unregisterSection]);
// Compact hero on scroll. scrollRef is the definitive scroll container
// (flex-1 min-h-0 overflow-y-auto inside a flex-col parent with min-h-0
// at every ancestor, so h-full never resolves to content-height).
@@ -150,6 +145,20 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
const { data: tasksList } = trpc.tasks.list.useQuery({ projectId });
const { data: eventsList } = trpc.timelineEvents.list.useQuery({ projectId });
useContextualScope({
page: 'project',
entityType: project ? 'project' : null,
entityId: project?.id,
entityName: project?.name,
counts: project
? {
tasks: tasksList?.length ?? 0,
notes: notesList?.length ?? 0,
milestones: (eventsList ?? []).filter((e) => e.type === 'milestone').length,
}
: undefined,
});
const breadcrumbPath = useMemo(() => {
if (!project?.clientId || !clientsList) return [];
const clientMap = new Map(clientsList.map((c) => [c.id, c]));
@@ -442,22 +451,58 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
</Breadcrumb>
</div>
)}
<h1
className={cn(
'font-semibold tracking-tight transition-[font-size,line-height] duration-200 ease-out',
compact ? 'text-base leading-tight' : 'text-3xl leading-tight',
)}
>
{projectName}
{projectName && subtitle && (
compact
? <span className="text-muted-foreground/60 font-normal"> · </span>
: <br />
)}
<span className={cn(compact ? 'text-muted-foreground/60 font-normal' : 'text-muted-foreground/50')}>
{subtitle}
</span>
</h1>
<div className="flex items-start gap-4">
<h1
className={cn(
'flex-1 min-w-0 font-semibold tracking-tight transition-[font-size,line-height] duration-200 ease-out',
compact ? 'text-base leading-tight' : 'text-3xl leading-tight',
)}
>
{projectName}
{projectName && subtitle && (
compact
? <span className="text-muted-foreground/60 font-normal"> · </span>
: <br />
)}
<span className={cn(compact ? 'text-muted-foreground/60 font-normal' : 'text-muted-foreground/50')}>
{subtitle}
</span>
</h1>
<div className="shrink-0 flex items-center gap-2">
<div
className={cn(
'overflow-hidden transition-all duration-200 ease-out',
compact ? 'max-w-0 max-h-0 opacity-0' : 'max-w-xs max-h-8 opacity-100',
)}
>
<FolderChip
projectId={project.id}
folderPath={project.folderPath ?? null}
totalFiles={project.folderTotalFiles ?? 0}
lastScannedAt={project.folderLastScannedAt ?? null}
scanStatus={(project.folderLastScanStatus ?? 'idle') as 'idle' | 'scanning' | 'error'}
scanProgress={scanStatus && scanStatus.status === 'scanning'
? { processed: scanStatus.processed, total: scanStatus.total }
: null}
onClick={() => {
const el = scrollRef.current;
const ref = filesRef.current;
if (el && ref) {
const heroH = heroRef.current?.getBoundingClientRect().height ?? 88;
const sectionTop = ref.getBoundingClientRect().top;
const containerTop = el.getBoundingClientRect().top;
const top = el.scrollTop + sectionTop - containerTop - heroH - 41;
el.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
}
void navigate({
search: (prev: Record<string, string | undefined>) => ({ ...prev, tab: 'files' }),
replace: true,
});
}}
/>
</div>
</div>
</div>
</div>
</div>
@@ -476,7 +521,6 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
<section
ref={summaryRef}
data-section="overview"
data-ai-section="project-summary"
className="flex flex-col gap-4"
>
<h1 className="text-2xl font-semibold tracking-tight">{t('projects.overview')}</h1>
@@ -536,8 +580,6 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
onEdit={handleEditEvent}
onDuplicate={handleDuplicate}
onMove={handleMoveEvent}
sectionId="project-timeline"
sectionLabel="Project Timeline"
projectId={projectId}
/>
</div>
@@ -558,28 +600,16 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
<section
ref={tasksRef}
data-section="tasks"
data-ai-section="project-tasks"
className="flex flex-col gap-4"
>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold tracking-tight">{t('projects.tasks')}</h1>
<Button size="sm" onClick={() => setNewTaskOpen(true)}>
<Plus data-icon="inline-start" />
{t('common.add')}
</Button>
</div>
<KanbanBoard
projectId={projectId}
newTaskOpen={newTaskOpen}
onNewTaskOpenChange={setNewTaskOpen}
/>
<h1 className="text-2xl font-semibold tracking-tight">{t('projects.tasks')}</h1>
<TaskListView projectId={projectId} hideProjectColumn />
</section>
{/* Notes section */}
<section
ref={notesRef}
data-section="notes"
data-ai-section="project-notes"
className="flex flex-col gap-4 pb-16"
>
<div className="flex items-center justify-between">
@@ -624,6 +654,22 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
)}
</section>
{/* Files section */}
<section
ref={filesRef}
data-section="files"
className="flex flex-col gap-4 pb-16"
>
<h1 className="text-2xl font-semibold tracking-tight">{t('projects.folder.title')}</h1>
<FilesSection
projectId={project.id}
folderPath={project.folderPath ?? null}
totalFiles={project.folderTotalFiles ?? 0}
lastScannedAt={project.folderLastScannedAt ?? null}
scanStatus={(project.folderLastScanStatus ?? 'idle') as 'idle' | 'scanning' | 'error'}
/>
</section>
</div>
</div>
);

View File

@@ -55,9 +55,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { useExpandedClients } from '@/context/ExpandedClientsContext';
import { Separator } from '@/components/ui/separator';
import {
Empty,
EmptyContent,
@@ -72,9 +70,11 @@ const NO_CLIENT_KEY = '__no_client__';
type ProjectSidebarProps = {
selectedProjectId: string | undefined;
onSelectProject: (id: string) => void;
newProjectOpen: boolean;
setNewProjectOpen: (open: boolean) => void;
};
export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSidebarProps) {
export function ProjectSidebar({ selectedProjectId, onSelectProject, newProjectOpen, setNewProjectOpen }: ProjectSidebarProps) {
const utils = trpc.useUtils();
const [showArchived, setShowArchived] = useState(false);
@@ -96,8 +96,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
const [editCreatingSubClient, setEditCreatingSubClient] = useState(false);
const [editNewSubClientName, setEditNewSubClientName] = useState('');
// New-project dialog state
const [newProjectOpen, setNewProjectOpen] = useState(false);
// New-project dialog state (open state is lifted to projects.tsx — see newProjectOpen prop)
const [newProjectName, setNewProjectName] = useState('');
const [newProjectClientId, setNewProjectClientId] = useState<string>(NO_CLIENT_KEY);
const [newProjectSubClientId, setNewProjectSubClientId] = useState<string>(NO_CLIENT_KEY);
@@ -108,6 +107,19 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
const [creatingSubClient, setCreatingSubClient] = useState(false);
const [newSubClientName, setNewSubClientName] = useState('');
// Reset form fields whenever the dialog opens (whether triggered from header or empty-state button)
useEffect(() => {
if (newProjectOpen) {
setNewProjectName('');
setNewProjectClientId(NO_CLIENT_KEY);
setNewProjectSubClientId(NO_CLIENT_KEY);
setCreatingClient(false);
setNewClientName('');
setCreatingSubClient(false);
setNewSubClientName('');
}
}, [newProjectOpen]);
const { data: projectList = [] } = trpc.projects.list.useQuery(
{ includeArchived: showArchived },
);
@@ -265,13 +277,6 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
}
function handleOpenNewProject() {
setNewProjectName('');
setNewProjectClientId(NO_CLIENT_KEY);
setNewProjectSubClientId(NO_CLIENT_KEY);
setCreatingClient(false);
setNewClientName('');
setCreatingSubClient(false);
setNewSubClientName('');
setNewProjectOpen(true);
}
@@ -410,25 +415,8 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
return (
<div className="flex flex-col h-full min-h-0 border-r border-border w-60 shrink-0">
{/* Header */}
<div className="flex h-14 items-center gap-2 px-3 shrink-0">
<SidebarTrigger />
<Separator orientation="vertical" className="data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px mr-2 data-[orientation=vertical]:h-4" />
<h4 className="text-sm font-medium text-foreground flex-1">{t('projects.projects')}</h4>
<Button
variant="outline"
size="icon"
className="size-7"
onClick={handleOpenNewProject}
disabled={createMutation.isPending}
aria-label={t('projects.newProject')}
>
<Plus />
</Button>
</div>
{/* Search */}
<div className="px-3 pb-2 shrink-0">
<div className="px-3 pt-2 pb-2 shrink-0">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input

View File

@@ -3,13 +3,13 @@ import { useNavigate } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
export const SECTIONS = ['overview', 'timeline', 'tasks', 'notes'] as const;
export const SECTIONS = ['overview', 'timeline', 'tasks', 'notes', 'files'] as const;
export type SectionId = typeof SECTIONS[number];
interface ProjectTabBarProps {
sectionRefs: Record<SectionId, RefObject<HTMLDivElement | null>>;
scrollRef: RefObject<HTMLDivElement | null>;
heroRef: RefObject<HTMLDivElement | null>;
heroRef?: RefObject<HTMLDivElement | null>;
initialTab?: string;
}
@@ -20,10 +20,23 @@ export function ProjectTabBar({ sectionRefs, scrollRef, heroRef, initialTab }: P
(SECTIONS.includes(initialTab as SectionId) ? initialTab : 'overview') as SectionId,
);
// Live hero height — kept in state so both the sticky top offset and the
// IntersectionObserver rootMargin update automatically when the hero resizes
// (compact ↔ expanded transition) or when it first appears after data loads.
const [heroH, setHeroH] = useState(0);
useEffect(() => {
const el = heroRef?.current;
if (!el) return;
const measure = () => setHeroH(el.getBoundingClientRect().height);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [heroRef]);
useEffect(() => {
const root = scrollRef.current;
if (!root) return;
const heroH = heroRef.current?.getBoundingClientRect().height ?? 88;
const tabBarH = 41;
const visible = new Map<SectionId, IntersectionObserverEntry>();
const observer = new IntersectionObserver(
@@ -56,7 +69,7 @@ export function ProjectTabBar({ sectionRefs, scrollRef, heroRef, initialTab }: P
if (ref.current) observer.observe(ref.current);
}
return () => observer.disconnect();
}, [sectionRefs, scrollRef, heroRef]);
}, [sectionRefs, scrollRef, heroH]);
const scrollToSection = useCallback((id: SectionId) => {
const el = scrollRef.current;
@@ -66,29 +79,31 @@ export function ProjectTabBar({ sectionRefs, scrollRef, heroRef, initialTab }: P
} else {
const ref = sectionRefs[id];
if (!ref?.current) return;
const heroH = heroRef.current?.getBoundingClientRect().height ?? 88;
const currentHeroH = heroRef?.current?.getBoundingClientRect().height ?? heroH;
const sectionTop = ref.current.getBoundingClientRect().top;
const containerTop = el.getBoundingClientRect().top;
const top = el.scrollTop + sectionTop - containerTop - heroH - 41;
const top = el.scrollTop + sectionTop - containerTop - currentHeroH - 41;
el.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
}
void navigate({
search: (prev: Record<string, string | undefined>) => ({ ...prev, tab: id }),
replace: true,
});
}, [sectionRefs, scrollRef, heroRef, navigate]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sectionRefs, scrollRef, heroRef, heroH, navigate]);
const TAB_LABELS: Record<SectionId, string> = {
overview: t('projects.overview'),
timeline: t('projects.projectTimeline'),
tasks: t('projects.tasks'),
notes: t('projects.notes'),
files: t('projects.folder.title'),
};
return (
<nav
className="sticky z-20 backdrop-blur-md border-b border-border/40"
style={{ top: 'var(--hero-h)' }}
style={{ top: heroH }}
>
<div className="mx-auto max-w-6xl px-8 flex gap-0">
{SECTIONS.map((id) => (

View File

@@ -0,0 +1,127 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { addMonths, startOfMonth, format } from 'date-fns';
import { Sparkles } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button';
import {
Empty,
EmptyHeader,
EmptyMedia,
EmptyTitle,
EmptyDescription,
} from '@/components/ui/empty';
import { useNotify } from '@/hooks/useNotify';
import { usePlatform } from '@/lib/platform';
import { FolderLinkCard } from './FolderLinkCard';
import { FolderFileList } from './FolderFileList';
import { FolderUnlinkDialog } from './FolderUnlinkDialog';
interface FilesSectionProps {
projectId: string;
folderPath: string | null;
totalFiles: number;
lastScannedAt: number | null;
scanStatus: 'idle' | 'scanning' | 'error' | null;
}
export function FilesSection({
projectId,
folderPath,
totalFiles,
lastScannedAt,
scanStatus,
}: FilesSectionProps) {
const { t } = useTranslation();
const { notify, notifyError } = useNotify();
const platform = usePlatform();
const [unlinkOpen, setUnlinkOpen] = useState(false);
const utils = trpc.useUtils();
const chooseFolder = trpc.projectFolders.chooseFolder.useMutation();
const link = trpc.projectFolders.link.useMutation({
onSuccess: () => utils.projects.get.invalidate({ id: projectId }),
onError: (err) => notifyError('errors.error', err),
});
const startScan = trpc.projectFolders.startScan.useMutation();
/** Parse a QUOTA error message from the tRPC FORBIDDEN payload. */
function handleScanError(err: { message?: string }): void {
const msg = err.message ?? '';
if (msg.startsWith('QUOTA:max_files:')) {
// Backend message format: "Folder has X files; tier 'free' allows max Y."
// Extract tier and max-count to pass to the i18n key.
const detail = msg.slice('QUOTA:max_files:'.length);
const tierMatch = detail.match(/tier '([^']+)'/);
const countMatch = detail.match(/allows max (\d+)/);
const tier = tierMatch?.[1] ?? 'your';
const count = countMatch ? parseInt(countMatch[1], 10) : 0;
notify('error', 'projects.folder.errors.tooBig', { values: { tier, count } });
return;
}
if (msg.startsWith('QUOTA:monthly_tokens:')) {
// Compute first day of next month as the reset date.
const resetDate = format(startOfMonth(addMonths(new Date(), 1)), 'PP');
notify('error', 'projects.folder.errors.monthlyExhausted', { values: { date: resetDate } });
return;
}
notifyError('errors.error', err);
}
const handleChoose = async () => {
const chosen = await chooseFolder.mutateAsync();
if (chosen) {
await link.mutateAsync({ projectId, folderPath: chosen });
// Kick first scan (fire-and-forget — progress shown via getStatus polling).
// Quota errors are caught here so we can show localised toasts.
startScan.mutate({ projectId }, { onError: handleScanError });
}
};
if (!platform.isElectron) {
return (
<div className="text-sm text-muted-foreground p-6 text-center">
{t('projects.folder.webOnlyTooltip')}
</div>
);
}
if (!folderPath) {
return (
<Empty>
<EmptyHeader>
<EmptyMedia>
<Sparkles />
</EmptyMedia>
<EmptyTitle>{t('projects.folder.empty.title')}</EmptyTitle>
<EmptyDescription>{t('projects.folder.empty.description')}</EmptyDescription>
</EmptyHeader>
<Button
onClick={handleChoose}
disabled={chooseFolder.isPending || link.isPending}
>
{t('projects.folder.empty.cta')}
</Button>
</Empty>
);
}
return (
<div className="space-y-4">
<FolderLinkCard
projectId={projectId}
folderPath={folderPath}
totalFiles={totalFiles}
lastScannedAt={lastScannedAt}
scanStatus={scanStatus}
onUnlinkRequested={() => setUnlinkOpen(true)}
/>
<FolderFileList projectId={projectId} />
<FolderUnlinkDialog
projectId={projectId}
open={unlinkOpen}
onOpenChange={setUnlinkOpen}
/>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { useTranslation } from 'react-i18next';
import { Folder, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatDistanceToNow } from 'date-fns';
interface FolderChipProps {
projectId: string;
folderPath: string | null;
totalFiles: number;
lastScannedAt: number | null;
scanStatus: 'idle' | 'scanning' | 'error' | null;
scanProgress?: { processed: number; total: number } | null;
onClick: () => void;
}
export function FolderChip({
folderPath,
totalFiles,
lastScannedAt,
scanStatus,
scanProgress,
onClick,
}: FolderChipProps) {
const { t } = useTranslation();
if (!folderPath) {
return (
<button
onClick={onClick}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border border-dashed border-border text-muted-foreground hover:text-foreground transition-colors"
>
<Sparkles className="h-3 w-3" />
{t('projects.folder.linkCta')}
</button>
);
}
if (scanStatus === 'scanning' && scanProgress) {
return (
<button
onClick={onClick}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-900 dark:text-amber-100"
>
<Folder className="h-3 w-3 animate-pulse" />
{t('projects.folder.scanning', {
processed: scanProgress.processed,
total: scanProgress.total,
})}
</button>
);
}
if (scanStatus === 'error') {
return (
<button
onClick={onClick}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-200"
>
<Folder className="h-3 w-3" />
{t('projects.folder.scanFailed')}
</button>
);
}
const relative = lastScannedAt
? formatDistanceToNow(new Date(lastScannedAt), { addSuffix: true })
: '—';
return (
<button
onClick={onClick}
className={cn(
'inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium',
'bg-[#fbc881]/20 hover:bg-[#fbc881]/30 transition-colors',
)}
>
<Folder className="h-3 w-3" />
<span>{t('projects.folder.filesCount', { count: totalFiles })}</span>
<span className="opacity-60">·</span>
<span className="opacity-70">{relative}</span>
</button>
);
}

View File

@@ -0,0 +1,70 @@
import { useState, useMemo } from 'react';
import { trpc } from '@/lib/trpc';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
interface FolderFileListProps {
projectId: string;
}
type Filter = 'all' | 'text' | 'image' | 'pdf' | 'docx';
const FILTERS: Filter[] = ['all', 'text', 'image', 'pdf', 'docx'];
export function FolderFileList({ projectId }: FolderFileListProps) {
const [filter, setFilter] = useState<Filter>('all');
const { data, isLoading } = trpc.projectFolders.listFiles.useQuery({ projectId });
const items = useMemo(() => {
if (!data) return [];
if (filter === 'all') return data;
return data.filter((f) => f.kind === filter);
}, [data, filter]);
if (isLoading) {
return (
<div className="space-y-2">
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-12" />
))}
</div>
);
}
return (
<div>
<div className="flex gap-2 mb-3 text-xs">
{FILTERS.map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={cn(
'px-2.5 py-1 rounded-full border border-border',
filter === f
? 'bg-foreground text-background'
: 'text-muted-foreground hover:text-foreground',
)}
>
{f}
</button>
))}
</div>
<ul className="space-y-1.5">
{items.map((f) => (
<li
key={f.id}
className={cn(
'rounded-md px-3 py-2 border border-border bg-background/50',
f.kind === 'skipped' && 'opacity-50',
)}
>
<div className="font-mono text-xs">{f.relativePath}</div>
{f.summary && (
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{f.summary}</div>
)}
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { useTranslation } from 'react-i18next';
import { Folder } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { formatDistanceToNow } from 'date-fns';
interface FolderLinkCardProps {
projectId: string;
folderPath: string;
totalFiles: number;
lastScannedAt: number | null;
scanStatus: 'idle' | 'scanning' | 'error' | null;
onUnlinkRequested: () => void;
}
export function FolderLinkCard({
projectId,
folderPath,
totalFiles,
lastScannedAt,
scanStatus,
onUnlinkRequested,
}: FolderLinkCardProps) {
const { t } = useTranslation();
const { notifyError } = useNotify();
const utils = trpc.useUtils();
const startScan = trpc.projectFolders.startScan.useMutation({
onSuccess: () => utils.projects.get.invalidate({ id: projectId }),
onError: (err) => notifyError('errors.error', err),
});
return (
<div className="flex items-center gap-3">
<Folder className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-mono text-xs text-muted-foreground truncate">{folderPath}</div>
<div className="text-xs text-muted-foreground/70 mt-0.5">
{t('projects.folder.filesCount', { count: totalFiles })}
{lastScannedAt && (
<>
{' · '}
{t('projects.folder.lastScanned', {
relative: formatDistanceToNow(new Date(lastScannedAt), { addSuffix: true }),
})}
</>
)}
</div>
</div>
<div className="flex gap-2 shrink-0">
<Button
variant="outline"
size="sm"
disabled={scanStatus === 'scanning' || startScan.isPending}
onClick={() => startScan.mutate({ projectId })}
>
{t('projects.folder.rescan')}
</Button>
<Button variant="ghost" size="sm" onClick={onUnlinkRequested}>
{t('projects.folder.unlink')}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
interface FolderUnlinkDialogProps {
projectId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function FolderUnlinkDialog({
projectId,
open,
onOpenChange,
}: FolderUnlinkDialogProps) {
const { t } = useTranslation();
const { notifyError } = useNotify();
const utils = trpc.useUtils();
const unlink = trpc.projectFolders.unlink.useMutation({
onSuccess: () => {
utils.projects.get.invalidate({ id: projectId });
utils.projectFolders.listFiles.invalidate({ projectId });
onOpenChange(false);
},
onError: (err) => notifyError('errors.error', err),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('projects.folder.unlink')}</DialogTitle>
<DialogDescription>
{t('projects.deleteProjectDescription')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={() => unlink.mutate({ projectId })}
disabled={unlink.isPending}
>
{unlink.isPending ? t('common.deleting') : t('projects.folder.unlink')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,165 +0,0 @@
import { useState } from 'react';
import { Bot, Plus } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { Button } from '@/components/ui/button';
import type { CloudAgentConfig } from '../../../../shared/api-types';
import type { LocalAgentConfig } from './types';
import { AgentRow } from './AgentRow';
import { InlineAgentCreationStepper } from './InlineAgentCreationStepper';
import { JourneyDialog } from './JourneyDialog';
import { useTranslation } from 'react-i18next';
export function AgentsSection() {
const { t } = useTranslation();
const utils = trpc.useUtils();
const localAgentsQuery = trpc.agent.local.list.useQuery();
const cloudAgentsQuery = trpc.agent.cloud.list.useQuery();
const deleteLocalMutation = trpc.agent.local.delete.useMutation();
const deleteCloudMutation = trpc.agent.cloud.delete.useMutation();
const updateLocalMutation = trpc.agent.local.update.useMutation();
const updateCloudMutation = trpc.agent.cloud.update.useMutation();
const runNowMutation = trpc.agent.runNow.useMutation();
const { notify, notifyError, notifyPromise } = useNotify();
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [journeyAgent, setJourneyAgent] = useState<{ id: string; type: 'local' | 'cloud'; name: string; currentConfig: Record<string, unknown> | null; dataTypes: string[]; directory?: string } | null>(null);
const catalogQuery = trpc.agent.catalog.useQuery(undefined, {
enabled: showTemplatePicker,
});
const localAgents: LocalAgentConfig[] = localAgentsQuery.data ?? [];
const cloudAgents: CloudAgentConfig[] = cloudAgentsQuery.data ?? [];
const allAgents = [
...localAgents.map(a => ({ ...a, agentType: 'local' as const })),
...cloudAgents.map(a => ({ ...a, agentType: 'cloud' as const })),
];
const hasAgents = allAgents.length > 0;
function handleDelete(id: string, type: 'local' | 'cloud') {
const mutation = type === 'local' ? deleteLocalMutation : deleteCloudMutation;
mutation.mutate({ id }, {
onSuccess: () => {
notify('warning', 'toast.agent.deleted');
void utils.agent.local.list.invalidate();
void utils.agent.cloud.list.invalidate();
},
onError: (err) => notifyError('toast.agent.deleteError', err),
});
}
function handleToggleEnabled(id: string, type: 'local' | 'cloud', enabled: boolean) {
if (type === 'local') {
updateLocalMutation.mutate({ id, enabled }, {
onSuccess: () => void utils.agent.local.list.invalidate(),
onError: (err) => notifyError('toast.agent.updateError', err),
});
} else {
updateCloudMutation.mutate({ id, enabled }, {
onSuccess: () => void utils.agent.cloud.list.invalidate(),
onError: (err) => notifyError('toast.agent.updateError', err),
});
}
}
function handleRunNow(id: string) {
const promise = runNowMutation.mutateAsync({ id });
notifyPromise(promise, { loading: 'toast.agent.runStarted', success: 'toast.agent.runStarted', error: 'toast.agent.runError' });
}
return (
<div className="flex flex-col gap-8">
{/* Empty first-run state */}
{!hasAgents && !showTemplatePicker && (
<div className="py-4 text-center">
<div className="size-11 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto mb-4">
<Bot className="size-5 text-primary" />
</div>
<h2 className="text-base font-semibold">{t('agents.noAgentsYet')}</h2>
<p className="text-sm text-muted-foreground max-w-md mx-auto mt-1.5">
{t('agents.noAgentsDescription')}
</p>
<Button size="sm" className="mt-5" onClick={() => setShowTemplatePicker(true)}>
<Plus className="size-3.5 mr-1.5" />
{t('agents.createFirstAgent')}
</Button>
</div>
)}
{/* Existing configured agents */}
{hasAgents && !showTemplatePicker && (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">{t('agents.yourAgents')}</h2>
<Button size="sm" variant="outline" onClick={() => setShowTemplatePicker(prev => !prev)}>
<Plus className="size-3.5 mr-1.5" />
{t('agents.createAgent')}
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2">
{allAgents.map((agent) => (
<AgentRow
key={agent.id}
agent={agent}
expanded={expandedAgent === agent.id}
onToggleExpand={() => setExpandedAgent(prev => prev === agent.id ? null : agent.id)}
onToggleEnabled={(enabled) => handleToggleEnabled(agent.id, agent.agentType, enabled)}
onDelete={() => handleDelete(agent.id, agent.agentType)}
onRunNow={() => handleRunNow(agent.id)}
onOpenJourney={() => setJourneyAgent({
id: agent.id,
type: agent.agentType,
name: agent.name,
currentConfig: agent.agentType === 'local' ? (agent as LocalAgentConfig).agentConfig ?? null : null,
dataTypes: agent.dataTypes,
directory: agent.agentType === 'local' ? (agent as LocalAgentConfig).directory : undefined,
})}
/>
))}
</div>
</div>
)}
{/* Backend templates picker */}
{showTemplatePicker && (
<InlineAgentCreationStepper
catalog={catalogQuery.data ?? []}
isLoadingCatalog={catalogQuery.isPending}
onCancel={() => setShowTemplatePicker(false)}
onCreated={() => {
setShowTemplatePicker(false);
void utils.agent.local.list.invalidate();
void utils.agent.cloud.list.invalidate();
}}
/>
)}
{/* Chatbot Journey dialog */}
{journeyAgent && (
<JourneyDialog
agentType={journeyAgent.type}
agentName={journeyAgent.name}
currentConfig={journeyAgent.currentConfig}
dataTypes={journeyAgent.dataTypes}
directory={journeyAgent.directory}
onClose={() => setJourneyAgent(null)}
onSaved={(agentConfig) => {
const local = localAgents.find(a => a.id === journeyAgent.id);
if (local) {
updateLocalMutation.mutate({ id: journeyAgent.id, agentConfig }, {
onSuccess: () => {
void utils.agent.local.list.invalidate();
setJourneyAgent(null);
},
});
} else {
setJourneyAgent(null);
}
}}
/>
)}
</div>
);
}

View File

@@ -1,102 +0,0 @@
import { useState } from 'react';
import { Sparkles } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { CloudAgentConfig } from '../../../../shared/api-types';
import { DATA_TYPES, SCHEDULE_OPTIONS } from './types';
export function CloudAgentConfigPanel({
agent,
onOpenJourney,
}: {
agent: CloudAgentConfig & { agentType: 'cloud' };
onOpenJourney: () => void;
}) {
const utils = trpc.useUtils();
const updateMutation = trpc.agent.cloud.update.useMutation();
const [dataTypes, setDataTypes] = useState<string[]>(agent.dataTypes ?? []);
const [schedule, setSchedule] = useState(agent.scheduleCron ?? '0 * * * *');
const { notify, notifyError } = useNotify();
function toggleDataType(type: string) {
setDataTypes(prev =>
prev.includes(type) ? prev.filter(t => t !== type) : [...prev, type],
);
}
function handleSave() {
updateMutation.mutate(
{ id: agent.id, dataTypes, scheduleCron: schedule },
{
onSuccess: () => {
notify('success', 'toast.agent.updated');
void utils.agent.cloud.list.invalidate();
},
onError: (err) => notifyError('toast.agent.updateError', err),
},
);
}
return (
<div className="flex flex-col gap-4">
{/* Provider info */}
<div className="flex items-center gap-2">
<Badge variant="outline" className="capitalize">{agent.provider}</Badge>
<span className="text-xs text-muted-foreground">Connected service</span>
</div>
{/* Data types */}
<div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-2">What to extract</label>
<div className="flex flex-wrap gap-4">
{DATA_TYPES.map(type => (
<label key={type} className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={dataTypes.includes(type)}
onCheckedChange={() => toggleDataType(type)}
/>
<span className="capitalize">{type}</span>
</label>
))}
</div>
</div>
{/* Schedule */}
<div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-2">Schedule</label>
<Select value={schedule} onValueChange={setSchedule}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCHEDULE_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* AI Prompt + Save */}
<div className="flex items-center justify-between pt-1">
<Button size="sm" variant="outline" onClick={onOpenJourney}>
<Sparkles className="size-3.5 mr-1.5" />
Customize AI prompt
</Button>
<Button size="sm" onClick={handleSave} disabled={updateMutation.isPending}>
Save changes
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,161 @@
import { useEffect, useState } from 'react';
import { Mail, X, Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import type { CloudScoutConfig } from '../../../shared/api-types';
export function CloudScoutConfigPanel({
scout,
}: {
scout: CloudScoutConfig & { scoutType: 'cloud' };
onOpenJourney: () => void;
}) {
const { t } = useTranslation();
const utils = trpc.useUtils();
const updateMutation = trpc.scout.cloud.update.useMutation();
const startOAuth = trpc.scout.cloud.startGmailOAuth.useMutation();
const completeOAuth = trpc.scout.cloud.completeGmailOAuth.useMutation();
const disconnect = trpc.scout.cloud.disconnectGmail.useMutation();
const { notify, notifyError } = useNotify();
const [focus, setFocus] = useState(scout.promptTemplate ?? '');
const [autoTrash, setAutoTrash] = useState(scout.autoTrashSpam ?? false);
const [labels, setLabels] = useState<string[]>(scout.filterConfig?.labels ?? []);
const [senders, setSenders] = useState<string[]>(scout.filterConfig?.senders ?? []);
const [senderInput, setSenderInput] = useState('');
const labelsQuery = trpc.scout.cloud.gmailLabels.useQuery(
{ scoutId: scout.id },
{ enabled: !!scout.oauthConnected },
);
useEffect(() => {
const electronAI = (window as unknown as { electronAI?: { onScoutGmailOAuthCallback?: (cb: (d: { code: string; state: string }) => void) => (() => void) } }).electronAI;
if (!electronAI?.onScoutGmailOAuthCallback) return;
const off = electronAI.onScoutGmailOAuthCallback(async ({ code, state }) => {
try {
await completeOAuth.mutateAsync({ code, state });
notify('success', 'toast.scout.gmailConnected');
void utils.scout.cloud.list.invalidate();
} catch (err) {
notifyError('toast.scout.updateError', err instanceof Error ? err : new Error(String(err)));
}
});
return () => { off?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function toggleLabel(id: string) {
setLabels(prev => prev.includes(id) ? prev.filter(l => l !== id) : [...prev, id]);
}
function addSender() {
const v = senderInput.trim();
if (v && !senders.includes(v)) setSenders(prev => [...prev, v]);
setSenderInput('');
}
function handleSave() {
updateMutation.mutate(
{ id: scout.id, promptTemplate: focus, autoTrashSpam: autoTrash, filterConfig: { labels, senders } },
{
onSuccess: () => { notify('success', 'toast.scout.updated'); void utils.scout.cloud.list.invalidate(); },
onError: (err) => notifyError('toast.scout.updateError', err),
},
);
}
function handleDisconnect() {
disconnect.mutate({ scoutId: scout.id }, {
onSuccess: () => { notify('warning', 'toast.scout.updated'); void utils.scout.cloud.list.invalidate(); },
onError: (err) => notifyError('toast.scout.updateError', err),
});
}
const connected = !!scout.oauthConnected;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Badge variant="outline" className="capitalize">{scout.provider}</Badge>
</div>
{/* Connection status */}
{connected ? (
<div className="flex items-center gap-3 rounded-md border px-3 py-2">
<Mail className="size-4 text-muted-foreground" />
<span className="text-xs flex-1">{t('scouts.connectedAs')} <span className="font-medium">{scout.gmailAddress ?? '—'}</span></span>
<Button size="sm" variant="outline" onClick={() => startOAuth.mutate({ scoutId: scout.id })}>{t('scouts.reconnect')}</Button>
<Button size="sm" variant="ghost" onClick={handleDisconnect} disabled={disconnect.isPending}>{t('scouts.disconnect')}</Button>
</div>
) : (
<div className="flex items-center gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 dark:border-amber-800 dark:bg-amber-950/30">
<span className="text-xs text-amber-700 dark:text-amber-400 flex-1">{t('scouts.gmailAccessRequired')}</span>
<Button size="sm" variant="outline" onClick={() => startOAuth.mutate({ scoutId: scout.id })} disabled={startOAuth.isPending}>
{t('scouts.connectGmail')}
</Button>
</div>
)}
{/* Focus */}
<div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-2">{t('scouts.focusLabel')}</label>
<Input value={focus} onChange={(e) => setFocus(e.target.value)} placeholder={t('scouts.focusPlaceholder')} />
</div>
{/* Filter — labels */}
{connected && (
<div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-2">{t('scouts.filterLabels')}</label>
{labelsQuery.isPending && <p className="text-xs text-muted-foreground">Loading labels</p>}
<div className="flex flex-wrap gap-3">
{(labelsQuery.data ?? []).map(lbl => (
<label key={lbl.id} className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={labels.includes(lbl.id)} onCheckedChange={() => toggleLabel(lbl.id)} />
<span>{lbl.name}</span>
</label>
))}
</div>
</div>
)}
{/* Filter — senders */}
<div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-2">{t('scouts.filterSenders')}</label>
<div className="flex gap-2">
<Input value={senderInput} onChange={(e) => setSenderInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSender(); } }}
placeholder={t('scouts.filterSendersPlaceholder')} />
<Button size="sm" variant="outline" onClick={addSender}><Plus className="size-3.5" /></Button>
</div>
{senders.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{senders.map(s => (
<span key={s} className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-xs">
{s}<button onClick={() => setSenders(prev => prev.filter(x => x !== s))}><X className="size-3" /></button>
</span>
))}
</div>
)}
</div>
{/* Auto-trash */}
<div className="flex items-center justify-between rounded-lg border px-4 py-3">
<div>
<p className="text-sm font-medium">{t('scouts.autoTrashSpam')}</p>
<p className="text-xs text-muted-foreground">{t('scouts.autoTrashHint')}</p>
</div>
<Switch checked={autoTrash} onCheckedChange={setAutoTrash} />
</div>
<div className="flex items-center justify-end pt-1">
<Button size="sm" onClick={handleSave} disabled={updateMutation.isPending}>{t('common.save')}</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,210 @@
import { useEffect, useState } from 'react';
import { Mail, X, Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import type { AgentCatalogItem } from '../../../shared/api-types';
export function CloudScoutCreationFlow({
template,
onCancel,
onCreated,
}: {
template: AgentCatalogItem;
onCancel: () => void;
onCreated: () => void;
}) {
const { t } = useTranslation();
const { notify, notifyError } = useNotify();
const startOAuthDraft = trpc.scout.cloud.startGmailOAuthDraft.useMutation();
const completeOAuth = trpc.scout.cloud.completeGmailOAuth.useMutation();
const finalizeMutation = trpc.scout.cloud.finalizeCloudScout.useMutation();
const [step, setStep] = useState<'basics' | 'filter'>('basics');
const [name, setName] = useState(template.name);
const [focus, setFocus] = useState('');
const [autoTrash, setAutoTrash] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const [connecting, setConnecting] = useState(false);
const [error, setError] = useState('');
const [labels, setLabels] = useState<string[]>([]);
const [senderInput, setSenderInput] = useState('');
const [senders, setSenders] = useState<string[]>([]);
const labelsQuery = trpc.scout.cloud.gmailSessionLabels.useQuery(
{ session: sessionId ?? '' },
{ enabled: step === 'filter' && !!sessionId },
);
// OAuth deep-link callback → complete, capture the session, advance to filter.
useEffect(() => {
const electronAI = (window as unknown as { electronAI?: { onScoutGmailOAuthCallback?: (cb: (d: { code: string; state: string }) => void) => (() => void) } }).electronAI;
if (!electronAI?.onScoutGmailOAuthCallback) return;
const off = electronAI.onScoutGmailOAuthCallback(async ({ code, state }) => {
try {
const res = await completeOAuth.mutateAsync({ code, state });
// sessionId is only present for create-mode flows; reconnect returns null.
if (res.sessionId) {
setSessionId(res.sessionId);
setConnecting(false);
setStep('filter');
notify('success', 'toast.scout.gmailConnected');
}
} catch (err) {
setConnecting(false);
notifyError('toast.scout.createError', err instanceof Error ? err : new Error(String(err)));
}
});
return () => { off?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function handleConnect() {
if (!name.trim()) { setError(t('scouts.nameRequired')); return; }
setError('');
setConnecting(true);
try {
// No scout is created here — the draft is held in the server-side session.
await startOAuthDraft.mutateAsync({
name,
promptTemplate: focus,
autoTrashSpam: autoTrash,
});
// Wait for the deep-link callback (handled in the effect).
} catch (err) {
setConnecting(false);
setError(err instanceof Error ? err.message : 'Failed to connect');
}
}
function toggleLabel(id: string) {
setLabels(prev => prev.includes(id) ? prev.filter(l => l !== id) : [...prev, id]);
}
function addSender() {
const v = senderInput.trim();
if (v && !senders.includes(v)) setSenders(prev => [...prev, v]);
setSenderInput('');
}
async function handleFinish() {
if (!sessionId) return;
try {
const res = await finalizeMutation.mutateAsync({
session: sessionId,
filterConfig: { labels, senders },
});
if (res.error || !res.data) throw new Error(res.error ?? 'finalize failed');
notify('success', 'toast.scout.created');
onCreated();
} catch (err) {
notifyError('toast.scout.createError', err instanceof Error ? err : new Error(String(err)));
}
}
if (step === 'basics') {
return (
<div className="flex flex-col gap-7 w-full">
<div className="pb-1">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 2 of 3</p>
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
Configure<br />
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="text-muted-foreground/50 bg-transparent outline-none border-none w-full placeholder:text-muted-foreground/30 caret-primary"
placeholder={t('scouts.namePlaceholder')}
spellCheck={false}
/>
</h2>
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">{t('scouts.gmailBasicsHint')}</p>
</div>
<div>
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">{t('scouts.focusLabel')}</label>
<Input value={focus} onChange={(e) => setFocus(e.target.value)} placeholder={t('scouts.focusPlaceholder')} />
</div>
<div className="flex items-center justify-between rounded-lg border px-4 py-3">
<div>
<p className="text-sm font-medium">{t('scouts.autoTrashSpam')}</p>
<p className="text-xs text-muted-foreground">{t('scouts.autoTrashHint')}</p>
</div>
<Switch checked={autoTrash} onCheckedChange={setAutoTrash} />
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex items-center justify-between pt-1">
<Button variant="outline" size="sm" onClick={onCancel}>Cancel</Button>
<Button size="sm" onClick={handleConnect} disabled={connecting}>
<Mail className="size-3.5 mr-1.5" />
{connecting ? t('scouts.connecting') : t('scouts.connectGmail')}
</Button>
</div>
</div>
);
}
// step === 'filter'
return (
<div className="flex flex-col gap-7 w-full">
<div className="pb-1">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 3 of 3</p>
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
Narrow the<br />
<span className="text-muted-foreground/50">emails it watches.</span>
</h2>
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">{t('scouts.filterHint')}</p>
</div>
<div>
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">{t('scouts.filterLabels')}</label>
{labelsQuery.isPending && <p className="text-xs text-muted-foreground">Loading labels</p>}
<div className="flex flex-wrap gap-3">
{(labelsQuery.data ?? []).map(lbl => (
<label key={lbl.id} className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={labels.includes(lbl.id)} onCheckedChange={() => toggleLabel(lbl.id)} />
<span>{lbl.name}</span>
</label>
))}
</div>
</div>
<div>
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">{t('scouts.filterSenders')}</label>
<div className="flex gap-2">
<Input
value={senderInput}
onChange={(e) => setSenderInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSender(); } }}
placeholder={t('scouts.filterSendersPlaceholder')}
/>
<Button size="sm" variant="outline" onClick={addSender}><Plus className="size-3.5" /></Button>
</div>
{senders.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{senders.map(s => (
<span key={s} className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-xs">
{s}
<button onClick={() => setSenders(prev => prev.filter(x => x !== s))}><X className="size-3" /></button>
</span>
))}
</div>
)}
<p className="text-xs text-muted-foreground mt-2">{t('scouts.watchAllInbox')}</p>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<Button variant="outline" size="sm" onClick={handleFinish}>{t('scouts.skipFilter')}</Button>
<Button size="sm" onClick={handleFinish} disabled={finalizeMutation.isPending}>{t('scouts.finish')}</Button>
</div>
</div>
);
}

View File

@@ -1,389 +0,0 @@
import { useState } from 'react';
import { Bot, FolderOpen, X, Sparkles } from 'lucide-react';
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 { cn } from '@/lib/utils';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
} from '@/components/ui/dialog';
import type { AgentCatalogItem } from '../../../../shared/api-types';
import { DATA_TYPE_CONFIG, SCHEDULE_OPTIONS } from './types';
import { TemplateSelectCard } from './TemplateSelectCard';
import { PromptBuilderChat } from './PromptBuilderChat';
export function InlineAgentCreationStepper({
catalog,
isLoadingCatalog,
onCancel,
onCreated,
}: {
catalog: AgentCatalogItem[];
isLoadingCatalog: boolean;
onCancel: () => void;
onCreated: () => void;
}) {
const createLocalMutation = trpc.agent.local.create.useMutation();
const createCloudMutation = trpc.agent.cloud.create.useMutation();
const { notify, notifyError } = useNotify();
const [step, setStep] = useState<1 | 2 | 3>(1);
const [selectedTemplate, setSelectedTemplate] = useState<AgentCatalogItem | null>(null);
const [name, setName] = useState('');
const [directory, setDirectory] = useState('');
const [promptDialogOpen, setPromptDialogOpen] = useState(false);
const [dataTypes, setDataTypes] = useState<string[]>([]);
const [schedule, setSchedule] = useState('0 * * * *');
const [promptTemplate, setPromptTemplate] = useState('');
const [agentConfig, setAgentConfig] = useState<Record<string, unknown> | null>(null);
const [error, setError] = useState('');
const isSubmitting = createLocalMutation.isPending || createCloudMutation.isPending;
function applyTemplateDefaults(item: AgentCatalogItem) {
setSelectedTemplate(item);
setName(item.name);
setDirectory('');
setDataTypes((item.supportedDataTypes ?? []).slice(0, 2));
setSchedule('0 * * * *');
setPromptTemplate('');
setAgentConfig(null);
setError('');
setStep(2);
}
async function pickDirectory() {
try {
const result = await window.electronDialog.showOpenDialog({
properties: ['openDirectory'],
title: 'Select directory for agent to watch',
});
if (!result.canceled && result.filePaths.length > 0) {
setDirectory(result.filePaths[0]!);
}
} catch {
// noop
}
}
function toggleDataType(type: string) {
setDataTypes(prev =>
prev.includes(type) ? prev.filter(t => t !== type) : [...prev, type],
);
}
function nextFromConfig() {
if (!selectedTemplate) return;
if (!name.trim()) {
setError('Agent name is required.');
return;
}
if (selectedTemplate.type === 'local_directory' && !directory) {
setError('Select a directory.');
return;
}
if (dataTypes.length === 0) {
setError('Select at least one data type.');
return;
}
setError('');
setStep(3);
}
function handleCreate() {
if (!selectedTemplate) return;
setError('');
if (selectedTemplate.type === 'local_directory') {
createLocalMutation.mutate(
{
name,
directory,
dataTypes,
scheduleCron: schedule,
agentConfig: agentConfig ?? null,
},
{
onSuccess: () => {
notify('success', 'toast.agent.created');
onCreated();
},
onError: (err) => {
notifyError('toast.agent.createError', err);
setError(err.message);
},
},
);
return;
}
createCloudMutation.mutate(
{
name,
provider: selectedTemplate.provider as 'gmail' | 'teams' | 'outlook',
dataTypes,
scheduleCron: schedule,
promptTemplate,
filterConfig: {},
},
{
onSuccess: () => {
notify('success', 'toast.agent.created');
onCreated();
},
onError: (err) => {
notifyError('toast.agent.createError', err);
setError(err.message);
},
},
);
}
return (
<div className="flex flex-col gap-5 w-full">
{step === 1 && (
<div className="flex flex-col gap-5">
<div className="pb-1">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 1 of 3</p>
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
Choose your<br />
<span className="text-muted-foreground/50">starting template.</span>
</h2>
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">Pick a starting point you can customize everything before the agent goes live.</p>
</div>
{isLoadingCatalog && (
<div className="rounded-lg border border-dashed px-4 py-10 text-sm text-muted-foreground text-center bg-muted/20">
Loading templates...
</div>
)}
{!isLoadingCatalog && catalog.length === 0 && (
<div className="rounded-xl border border-dashed px-6 py-10 text-center">
<Bot className="size-8 mx-auto mb-3 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">No templates available yet. Add your server URL in Account settings, then try again.</p>
</div>
)}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{catalog.map((item) => (
<TemplateSelectCard
key={item.id}
item={item}
selected={selectedTemplate?.id === item.id}
onSelect={() => applyTemplateDefaults(item)}
/>
))}
</div>
</div>
)}
{step === 2 && selectedTemplate && (
<div className="flex flex-col gap-7">
<div className="pb-1">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 2 of 3</p>
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
Configure<br />
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="text-muted-foreground/50 bg-transparent outline-none border-none w-full placeholder:text-muted-foreground/30 caret-primary"
placeholder="agent name."
spellCheck={false}
/>
</h2>
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">
{selectedTemplate.type === 'local_directory'
? 'Point it at your files, pick what to extract, and set the run schedule.'
: 'Pick what to extract and set the run schedule.'}
</p>
</div>
{/* Local directory: path picker */}
{selectedTemplate.type === 'local_directory' && (
<div>
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">Directory</label>
{directory && (
<div className="flex items-center gap-2 bg-muted/40 rounded-lg px-3 py-2 text-xs font-mono mb-2">
<FolderOpen className="size-3 text-muted-foreground shrink-0" />
<span className="flex-1 truncate">{directory}</span>
<button onClick={() => setDirectory('')} className="text-muted-foreground hover:text-foreground transition-colors">
<X className="size-3" />
</button>
</div>
)}
<Button size="sm" variant="outline" onClick={pickDirectory}>
<FolderOpen className="size-3.5 mr-1.5" />
{directory ? 'Change directory' : 'Select directory'}
</Button>
</div>
)}
{/* Cloud: sign-in notice */}
{selectedTemplate.type !== 'local_directory' && (
<div className="rounded-xl border border-dashed px-4 py-3 text-sm text-muted-foreground">
After creating this agent, you'll be asked to sign in to <span className="font-medium text-foreground capitalize">{selectedTemplate.provider}</span> and grant read access.
</div>
)}
{/* What to extract */}
<div>
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-3">What to extract</label>
<div className="flex flex-wrap gap-2">
{DATA_TYPE_CONFIG.map(({ value, label, Icon }) => {
const active = dataTypes.includes(value);
return (
<button
key={value}
onClick={() => toggleDataType(value)}
className={cn(
'inline-flex items-center gap-2 px-3.5 py-2 rounded-xl border text-sm font-medium transition-all duration-150',
active
? 'bg-foreground text-background border-foreground'
: 'bg-background text-muted-foreground border-border/60 hover:border-border hover:text-foreground',
)}
>
<Icon className="size-3.5" />
{label}
</button>
);
})}
</div>
</div>
{/* Batch interval */}
<div>
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">Batch interval</label>
<Select value={schedule} onValueChange={setSchedule}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCHEDULE_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Extraction prompt builder */}
{(() => {
const unlocked = (selectedTemplate.type !== 'local_directory' || !!directory) && dataTypes.length > 0;
return (
<div>
<Button
variant={promptTemplate || agentConfig ? 'outline' : 'default'}
size="sm"
disabled={!unlocked}
onClick={() => setPromptDialogOpen(true)}
>
<Sparkles className="size-3.5 mr-1.5" />
{promptTemplate || agentConfig ? 'Edit extraction prompt' : 'Build extraction prompt'}
</Button>
<Dialog open={promptDialogOpen} onOpenChange={setPromptDialogOpen}>
<DialogContent showCloseButton={false} className="w-[95vw] max-w-[1100px] h-[75vh] max-h-[780px] flex flex-col gap-0 p-0 overflow-hidden">
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
<PromptBuilderChat
autoStart
agentType={selectedTemplate.type === 'local_directory' ? 'local_directory' : selectedTemplate.provider as 'gmail' | 'teams' | 'outlook'}
dataTypes={dataTypes}
directory={selectedTemplate.type === 'local_directory' ? directory : undefined}
onPromptUpdate={(p) => setPromptTemplate(p)}
onConfigUpdate={(c) => setAgentConfig(c)}
/>
</div>
<div className="flex justify-end gap-2 px-5 py-4 border-t shrink-0">
<Button variant="outline" size="sm" onClick={() => setPromptDialogOpen(false)}>
Close
</Button>
<Button size="sm" onClick={() => setPromptDialogOpen(false)}>
Confirm
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
})()}
</div>
)}
{step === 3 && selectedTemplate && (
<div className="flex flex-col gap-5">
<div className="pb-1">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 3 of 3</p>
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
Review and<br />
<span className="text-muted-foreground/50">create your agent.</span>
</h2>
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">Everything looks good? Hit create and your agent will start running on schedule.</p>
</div>
<Card className="rounded-xl gap-0 py-0 shadow-none border-border/70">
<CardContent className="p-5 flex flex-col gap-4">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Template</p>
<p className="text-sm font-medium">{selectedTemplate.name}</p>
</div>
<div className="border rounded-lg px-4 py-3 grid gap-2 text-sm bg-background">
<p><span className="text-muted-foreground">Name:</span> {name}</p>
<p><span className="text-muted-foreground">Type:</span> {selectedTemplate.type}</p>
<p><span className="text-muted-foreground">Data types:</span> {dataTypes.join(', ') || 'None'}</p>
<p><span className="text-muted-foreground">Schedule:</span> {SCHEDULE_OPTIONS.find(s => s.value === schedule)?.label ?? schedule}</p>
{selectedTemplate.type === 'local_directory' && directory && (
<p><span className="text-muted-foreground">Directory:</span> {directory}</p>
)}
{(selectedTemplate.type === 'local_directory' ? agentConfig : promptTemplate) && (
<p><span className="text-muted-foreground">Extraction config:</span> Added</p>
)}
</div>
</CardContent>
</Card>
</div>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex items-center justify-between pt-1">
<Button
variant="outline"
size="sm"
onClick={() => {
setError('');
if (step === 1) {
onCancel();
} else {
setStep(prev => (prev - 1) as 1 | 2 | 3);
}
}}
>
{step === 1 ? 'Cancel' : 'Back'}
</Button>
{step === 2 && (
<Button size="sm" onClick={nextFromConfig}>
Next
</Button>
)}
{step === 3 && (
<Button size="sm" onClick={handleCreate} disabled={isSubmitting}>
Create agent now
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { useState } from 'react';
import { Bot } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { AgentCatalogItem } from '../../../shared/api-types';
import { TemplateSelectCard } from './TemplateSelectCard';
import { LocalScoutCreationFlow } from './LocalScoutCreationFlow';
import { CloudScoutCreationFlow } from './CloudScoutCreationFlow';
export function InlineScoutCreationStepper({
catalog,
isLoadingCatalog,
onCancel,
onCreated,
}: {
catalog: AgentCatalogItem[];
isLoadingCatalog: boolean;
onCancel: () => void;
onCreated: () => void;
}) {
const [selectedTemplate, setSelectedTemplate] = useState<AgentCatalogItem | null>(null);
if (selectedTemplate) {
return selectedTemplate.type === 'local_directory'
? <LocalScoutCreationFlow template={selectedTemplate} onCancel={() => setSelectedTemplate(null)} onCreated={onCreated} />
: <CloudScoutCreationFlow template={selectedTemplate} onCancel={() => setSelectedTemplate(null)} onCreated={onCreated} />;
}
return (
<div className="flex flex-col gap-5 w-full">
<div className="pb-1">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 1 of 3</p>
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
Choose your<br />
<span className="text-muted-foreground/50">starting template.</span>
</h2>
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">Pick a starting point you can customize everything before the scout goes live.</p>
</div>
{isLoadingCatalog && (
<div className="rounded-lg border border-dashed px-4 py-10 text-sm text-muted-foreground text-center bg-muted/20">Loading templates...</div>
)}
{!isLoadingCatalog && catalog.length === 0 && (
<div className="rounded-xl border border-dashed px-6 py-10 text-center">
<Bot className="size-8 mx-auto mb-3 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">No templates available yet. Add your server URL in Account settings, then try again.</p>
</div>
)}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{catalog.map((item) => (
<TemplateSelectCard key={item.id} item={item} selected={false} onSelect={() => setSelectedTemplate(item)} />
))}
</div>
<div className="flex items-center justify-between pt-1">
<Button variant="outline" size="sm" onClick={onCancel}>Cancel</Button>
</div>
</div>
);
}

View File

@@ -70,8 +70,8 @@ export function JourneyDialog({
onClose: () => void;
onSaved: (agentConfig: Record<string, unknown>) => void;
}) {
const startMutation = trpc.agent.journey.start.useMutation();
const messageMutation = trpc.agent.journey.message.useMutation();
const startMutation = trpc.scout.journey.start.useMutation();
const messageMutation = trpc.scout.journey.message.useMutation();
const [sessionId, setSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<JourneyMessage[]>([]);

View File

@@ -11,29 +11,29 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { LocalAgentConfig } from './types';
import type { LocalScoutConfig } from './types';
import { DATA_TYPES, SCHEDULE_OPTIONS } from './types';
export function LocalAgentConfigPanel({
agent,
export function LocalScoutConfigPanel({
scout,
onOpenJourney,
}: {
agent: LocalAgentConfig & { agentType: 'local' };
scout: LocalScoutConfig & { scoutType: 'local' };
onOpenJourney: () => void;
}) {
const utils = trpc.useUtils();
const updateMutation = trpc.agent.local.update.useMutation();
const updateMutation = trpc.scout.local.update.useMutation();
const [directory, setDirectory] = useState(agent.directory ?? '');
const [dataTypes, setDataTypes] = useState<string[]>(agent.dataTypes ?? []);
const [schedule, setSchedule] = useState(agent.scheduleCron ?? '0 * * * *');
const [directory, setDirectory] = useState(scout.directory ?? '');
const [dataTypes, setDataTypes] = useState<string[]>(scout.dataTypes ?? []);
const [schedule, setSchedule] = useState(scout.scheduleCron ?? '0 * * * *');
const { notify, notifyError } = useNotify();
async function pickDirectory() {
try {
const result = await window.electronDialog.showOpenDialog({
properties: ['openDirectory'],
title: 'Select directory for agent to watch',
title: 'Select directory for scout to watch',
});
if (!result.canceled && result.filePaths.length > 0) {
setDirectory(result.filePaths[0]!);
@@ -51,13 +51,13 @@ export function LocalAgentConfigPanel({
function handleSave() {
updateMutation.mutate(
{ id: agent.id, directory, dataTypes, scheduleCron: schedule },
{ id: scout.id, directory, dataTypes, scheduleCron: schedule },
{
onSuccess: () => {
notify('success', 'toast.agent.updated');
void utils.agent.local.list.invalidate();
notify('success', 'toast.scout.updated');
void utils.scout.local.list.invalidate();
},
onError: (err) => notifyError('toast.agent.updateError', err),
onError: (err) => notifyError('toast.scout.updateError', err),
},
);
}

View File

@@ -0,0 +1,206 @@
import { useState } from 'react';
import { FolderOpen, X, Sparkles } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import type { AgentCatalogItem } from '../../../shared/api-types';
import { DATA_TYPE_CONFIG, SCHEDULE_OPTIONS } from './types';
import { PromptBuilderChat } from './PromptBuilderChat';
export function LocalScoutCreationFlow({
template,
onCancel,
onCreated,
}: {
template: AgentCatalogItem;
onCancel: () => void;
onCreated: () => void;
}) {
const createLocalMutation = trpc.scout.local.create.useMutation();
const { notify, notifyError } = useNotify();
const [step, setStep] = useState<2 | 3>(2);
const [name, setName] = useState(template.name);
const [directory, setDirectory] = useState('');
const [promptDialogOpen, setPromptDialogOpen] = useState(false);
const [dataTypes, setDataTypes] = useState<string[]>((template.supportedDataTypes ?? []).slice(0, 2));
const [schedule, setSchedule] = useState('0 * * * *');
const [promptTemplate, setPromptTemplate] = useState('');
const [scoutConfig, setScoutConfig] = useState<Record<string, unknown> | null>(null);
const [error, setError] = useState('');
const isSubmitting = createLocalMutation.isPending;
async function pickDirectory() {
try {
const result = await window.electronDialog.showOpenDialog({
properties: ['openDirectory'],
title: 'Select directory for scout to watch',
});
if (!result.canceled && result.filePaths.length > 0) setDirectory(result.filePaths[0]!);
} catch { /* noop */ }
}
function toggleDataType(type: string) {
setDataTypes(prev => prev.includes(type) ? prev.filter(t => t !== type) : [...prev, type]);
}
function nextFromConfig() {
if (!name.trim()) { setError('Scout name is required.'); return; }
if (!directory) { setError('Select a directory.'); return; }
if (dataTypes.length === 0) { setError('Select at least one data type.'); return; }
setError(''); setStep(3);
}
function handleCreate() {
setError('');
createLocalMutation.mutate(
{ name, directory, dataTypes, scheduleCron: schedule, agentConfig: scoutConfig ?? null },
{
onSuccess: () => { notify('success', 'toast.scout.created'); onCreated(); },
onError: (err) => { notifyError('toast.scout.createError', err); setError(err.message); },
},
);
}
const unlocked = !!directory && dataTypes.length > 0;
return (
<div className="flex flex-col gap-7 w-full">
{step === 2 && (
<>
<div className="pb-1">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 2 of 3</p>
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
Configure<br />
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="text-muted-foreground/50 bg-transparent outline-none border-none w-full placeholder:text-muted-foreground/30 caret-primary"
placeholder="scout name."
spellCheck={false}
/>
</h2>
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">Point it at your files, pick what to extract, and set the run schedule.</p>
</div>
<div>
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">Directory</label>
{directory && (
<div className="flex items-center gap-2 bg-muted/40 rounded-lg px-3 py-2 text-xs font-mono mb-2">
<FolderOpen className="size-3 text-muted-foreground shrink-0" />
<span className="flex-1 truncate">{directory}</span>
<button onClick={() => setDirectory('')} className="text-muted-foreground hover:text-foreground transition-colors">
<X className="size-3" />
</button>
</div>
)}
<Button size="sm" variant="outline" onClick={pickDirectory}>
<FolderOpen className="size-3.5 mr-1.5" />
{directory ? 'Change directory' : 'Select directory'}
</Button>
</div>
<div>
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-3">What to extract</label>
<div className="flex flex-wrap gap-2">
{DATA_TYPE_CONFIG.map(({ value, label, Icon }) => {
const active = dataTypes.includes(value);
return (
<button key={value} onClick={() => toggleDataType(value)}
className={cn(
'inline-flex items-center gap-2 px-3.5 py-2 rounded-xl border text-sm font-medium transition-all duration-150',
active ? 'bg-foreground text-background border-foreground'
: 'bg-background text-muted-foreground border-border/60 hover:border-border hover:text-foreground',
)}>
<Icon className="size-3.5" />
{label}
</button>
);
})}
</div>
</div>
<div>
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">Batch interval</label>
<Select value={schedule} onValueChange={setSchedule}>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
{SCHEDULE_OPTIONS.map(opt => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Button variant={promptTemplate || scoutConfig ? 'outline' : 'default'} size="sm" disabled={!unlocked} onClick={() => setPromptDialogOpen(true)}>
<Sparkles className="size-3.5 mr-1.5" />
{promptTemplate || scoutConfig ? 'Edit extraction prompt' : 'Build extraction prompt'}
</Button>
<Dialog open={promptDialogOpen} onOpenChange={setPromptDialogOpen}>
<DialogContent showCloseButton={false} className="w-[95vw] max-w-[1100px] h-[75vh] max-h-[780px] flex flex-col gap-0 p-0 overflow-hidden">
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
<PromptBuilderChat
autoStart
agentType="local_directory"
dataTypes={dataTypes}
directory={directory}
onPromptUpdate={(p) => setPromptTemplate(p)}
onConfigUpdate={(c) => setScoutConfig(c)}
/>
</div>
<div className="flex justify-end gap-2 px-5 py-4 border-t shrink-0">
<Button variant="outline" size="sm" onClick={() => setPromptDialogOpen(false)}>Close</Button>
<Button size="sm" onClick={() => setPromptDialogOpen(false)}>Confirm</Button>
</div>
</DialogContent>
</Dialog>
</div>
</>
)}
{step === 3 && (
<div className="flex flex-col gap-5">
<div className="pb-1">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 3 of 3</p>
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
Review and<br />
<span className="text-muted-foreground/50">create your scout.</span>
</h2>
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">Everything looks good? Hit create and your scout will start running on schedule.</p>
</div>
<Card className="rounded-xl gap-0 py-0 shadow-none border-border/70">
<CardContent className="p-5 flex flex-col gap-4">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Template</p>
<p className="text-sm font-medium">{template.name}</p>
</div>
<div className="border rounded-lg px-4 py-3 grid gap-2 text-sm bg-background">
<p><span className="text-muted-foreground">Name:</span> {name}</p>
<p><span className="text-muted-foreground">Data types:</span> {dataTypes.join(', ') || 'None'}</p>
<p><span className="text-muted-foreground">Schedule:</span> {SCHEDULE_OPTIONS.find(s => s.value === schedule)?.label ?? schedule}</p>
<p><span className="text-muted-foreground">Directory:</span> {directory}</p>
{scoutConfig && <p><span className="text-muted-foreground">Extraction config:</span> Added</p>}
</div>
</CardContent>
</Card>
</div>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex items-center justify-between pt-1">
<Button variant="outline" size="sm" onClick={() => { setError(''); step === 2 ? onCancel() : setStep(2); }}>
{step === 2 ? 'Cancel' : 'Back'}
</Button>
{step === 2 && <Button size="sm" onClick={nextFromConfig}>Next</Button>}
{step === 3 && <Button size="sm" onClick={handleCreate} disabled={isSubmitting}>Create scout now</Button>}
</div>
</div>
);
}

View File

@@ -20,8 +20,8 @@ export function PromptBuilderChat({
onPromptUpdate?: (prompt: string) => void;
onConfigUpdate?: (config: Record<string, unknown>) => void;
}) {
const startMutation = trpc.agent.journey.start.useMutation();
const messageMutation = trpc.agent.journey.message.useMutation();
const startMutation = trpc.scout.journey.start.useMutation();
const messageMutation = trpc.scout.journey.message.useMutation();
const [started, setStarted] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);

View File

@@ -3,15 +3,15 @@ import { Play, Trash2, ChevronDown, ChevronUp, History } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import type { CloudAgentConfig } from '../../../../shared/api-types';
import type { LocalAgentConfig } from './types';
import type { CloudScoutConfig } from '../../../shared/api-types';
import type { LocalScoutConfig } from './types';
import { SCHEDULE_OPTIONS, formatTs } from './types';
import { LocalAgentConfigPanel } from './LocalAgentConfigPanel';
import { CloudAgentConfigPanel } from './CloudAgentConfigPanel';
import { AgentRunHistorySheet } from './AgentRunHistorySheet';
import { LocalScoutConfigPanel } from './LocalScoutConfigPanel';
import { CloudScoutConfigPanel } from './CloudScoutConfigPanel';
import { ScoutRunHistorySheet } from './ScoutRunHistorySheet';
export function AgentRow({
agent,
export function ScoutRow({
scout,
expanded,
onToggleExpand,
onToggleEnabled,
@@ -19,7 +19,7 @@ export function AgentRow({
onRunNow,
onOpenJourney,
}: {
agent: (LocalAgentConfig | CloudAgentConfig) & { agentType: 'local' | 'cloud' };
scout: (LocalScoutConfig | CloudScoutConfig) & { scoutType: 'local' | 'cloud' };
expanded: boolean;
onToggleExpand: () => void;
onToggleEnabled: (enabled: boolean) => void;
@@ -28,9 +28,10 @@ export function AgentRow({
onOpenJourney: () => void;
}) {
const [historyOpen, setHistoryOpen] = useState(false);
const scheduleLabel = SCHEDULE_OPTIONS.find(s => s.value === agent.scheduleCron)?.label ?? agent.scheduleCron;
const lastRunLabel = agent.lastRunAt ? formatTs(agent.lastRunAt) : 'Never';
const kindLabel = agent.agentType === 'local' ? 'Local' : `Cloud · ${(agent as CloudAgentConfig).provider}`;
const isCloud = scout.scoutType === 'cloud';
const scheduleLabel = SCHEDULE_OPTIONS.find(s => s.value === scout.scheduleCron)?.label ?? scout.scheduleCron;
const lastRunLabel = scout.lastRunAt ? formatTs(scout.lastRunAt) : 'Never';
const kindLabel = scout.scoutType === 'local' ? 'Local' : `Cloud · ${(scout as CloudScoutConfig).provider}`;
return (
<Card className="rounded-xl py-0 gap-0 overflow-hidden h-fit border-border/70 shadow-none">
@@ -38,38 +39,40 @@ export function AgentRow({
<div className="px-4 py-4 space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold truncate">{agent.name}</p>
<p className="text-sm font-semibold truncate">{scout.name}</p>
<p className="text-xs text-muted-foreground mt-1">{kindLabel}</p>
</div>
<Switch
checked={agent.enabled}
checked={scout.enabled}
onCheckedChange={onToggleEnabled}
size="sm"
/>
</div>
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1 text-xs">
<span className="text-muted-foreground">Schedule</span>
<span className="text-foreground truncate">{scheduleLabel}</span>
<span className="text-muted-foreground">Trigger</span>
<span className="text-foreground truncate">{isCloud ? 'Real-time' : scheduleLabel}</span>
<span className="text-muted-foreground">Last run</span>
<span className="text-foreground truncate">{lastRunLabel}</span>
<span className="text-muted-foreground">Status</span>
<span className="text-foreground">{agent.enabled ? 'Enabled' : 'Disabled'}</span>
<span className="text-foreground">{scout.enabled ? 'Enabled' : 'Disabled'}</span>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<Button size="sm" variant="outline" onClick={onRunNow} className="h-8">
<Play className="size-3.5 mr-1.5" />
Run now
</Button>
{!isCloud && (
<Button size="sm" variant="outline" onClick={onRunNow} className="h-8">
<Play className="size-3.5 mr-1.5" />
Run now
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setHistoryOpen(true)} className="h-8">
<History className="size-3.5 mr-1.5" />
History
</Button>
</div>
<div className="flex items-center gap-1">
<Button size="sm" variant="ghost" onClick={onDelete} title="Delete agent" className="h-8 w-8 p-0">
<Button size="sm" variant="ghost" onClick={onDelete} title="Delete scout" className="h-8 w-8 p-0">
<Trash2 className="size-3.5 text-muted-foreground" />
</Button>
<Button size="sm" variant="ghost" onClick={onToggleExpand} title={expanded ? 'Collapse' : 'Configure'} className="h-8 w-8 p-0">
@@ -82,17 +85,17 @@ export function AgentRow({
{/* Expanded config */}
{expanded && (
<div className="border-t px-4 py-4 bg-muted/20">
{agent.agentType === 'local' ? (
<LocalAgentConfigPanel agent={agent as LocalAgentConfig & { agentType: 'local' }} onOpenJourney={onOpenJourney} />
{scout.scoutType === 'local' ? (
<LocalScoutConfigPanel scout={scout as LocalScoutConfig & { scoutType: 'local' }} onOpenJourney={onOpenJourney} />
) : (
<CloudAgentConfigPanel agent={agent as CloudAgentConfig & { agentType: 'cloud' }} onOpenJourney={onOpenJourney} />
<CloudScoutConfigPanel scout={scout as CloudScoutConfig & { scoutType: 'cloud' }} onOpenJourney={onOpenJourney} />
)}
</div>
)}
<AgentRunHistorySheet
agentId={agent.id}
agentName={agent.name}
<ScoutRunHistorySheet
scoutId={scout.id}
scoutName={scout.name}
open={historyOpen}
onOpenChange={setHistoryOpen}
/>

View File

@@ -17,7 +17,7 @@ import { useFormatPrefs, formatTs, formatDuration } from '@/lib/date';
type RunSummary = {
id: string;
agentId: string;
scoutId: string;
status: 'running' | 'completed' | 'failed' | 'partial';
startedAt: number;
completedAt: number | null | undefined;
@@ -88,7 +88,7 @@ const VERB_ICON: Record<string, React.ReactNode> = {
// ---------------------------------------------------------------------------
function RunActionList({ runId }: { runId: string }) {
const query = trpc.agent.runActions.useQuery({ runId });
const query = trpc.scout.runActions.useQuery({ runId });
if (query.isPending) {
return (
@@ -166,19 +166,19 @@ function RunRow({ run }: { run: RunSummary }) {
// Sheet
// ---------------------------------------------------------------------------
export function AgentRunHistorySheet({
agentId,
agentName,
export function ScoutRunHistorySheet({
scoutId,
scoutName,
open,
onOpenChange,
}: {
agentId: string;
agentName: string;
scoutId: string;
scoutName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const runsQuery = trpc.agent.runs.useQuery(
{ agentId, limit: 30 },
const runsQuery = trpc.scout.runs.useQuery(
{ agentId: scoutId, limit: 30 },
{ enabled: open },
);
@@ -188,7 +188,7 @@ export function AgentRunHistorySheet({
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-5 pt-5 pb-4">
<SheetTitle className="text-base font-semibold">{agentName}</SheetTitle>
<SheetTitle className="text-base font-semibold">{scoutName}</SheetTitle>
<p className="text-xs text-muted-foreground -mt-1">Run history</p>
</SheetHeader>
@@ -208,7 +208,7 @@ export function AgentRunHistorySheet({
</EmptyMedia>
<EmptyTitle className="text-sm">No runs yet</EmptyTitle>
<EmptyDescription className="text-xs">
Runs will appear here after the agent executes.
Runs will appear here after the scout executes.
</EmptyDescription>
</EmptyHeader>
</Empty>

View File

@@ -0,0 +1,180 @@
import { useState, useEffect } from 'react';
import { Bot, Plus } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { Button } from '@/components/ui/button';
import type { CloudScoutConfig } from '../../../shared/api-types';
import type { LocalScoutConfig } from './types';
import { ScoutRow } from './ScoutRow';
import { InlineScoutCreationStepper } from './InlineScoutCreationStepper';
import { JourneyDialog } from './JourneyDialog';
import { useTranslation } from 'react-i18next';
export function ScoutsSection({ onCreatingChange }: { onCreatingChange?: (creating: boolean) => void }) {
const { t } = useTranslation();
const utils = trpc.useUtils();
const localScoutsQuery = trpc.scout.local.list.useQuery();
const cloudScoutsQuery = trpc.scout.cloud.list.useQuery();
const deleteLocalMutation = trpc.scout.local.delete.useMutation();
const deleteCloudMutation = trpc.scout.cloud.delete.useMutation();
const updateLocalMutation = trpc.scout.local.update.useMutation();
const updateCloudMutation = trpc.scout.cloud.update.useMutation();
const runNowMutation = trpc.scout.runNow.useMutation();
const { notify, notifyError, notifyPromise } = useNotify();
const [expandedScout, setExpandedScout] = useState<string | null>(null);
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [journeyScout, setJourneyScout] = useState<{ id: string; type: 'local' | 'cloud'; name: string; currentConfig: Record<string, unknown> | null; dataTypes: string[]; directory?: string } | null>(null);
const catalogQuery = trpc.scout.catalog.useQuery(undefined, {
enabled: showTemplatePicker,
});
// Notify the settings page so it can hide its section header during creation.
useEffect(() => {
onCreatingChange?.(showTemplatePicker);
}, [showTemplatePicker, onCreatingChange]);
// Reset on unmount (e.g. switching settings sections mid-creation).
useEffect(() => () => onCreatingChange?.(false), [onCreatingChange]);
const localScouts: LocalScoutConfig[] = localScoutsQuery.data ?? [];
const cloudScouts: CloudScoutConfig[] = cloudScoutsQuery.data ?? [];
const allScouts = [
...localScouts.map(a => ({ ...a, scoutType: 'local' as const })),
...cloudScouts.map(a => ({ ...a, scoutType: 'cloud' as const })),
];
const hasScouts = allScouts.length > 0;
function handleDelete(id: string, type: 'local' | 'cloud') {
const mutation = type === 'local' ? deleteLocalMutation : deleteCloudMutation;
mutation.mutate({ id }, {
// The cloud/local delete procedures resolve with { success, error } and
// never throw, so onSuccess fires even on a backend failure. Branch on
// the returned success flag to avoid a misleading "deleted" toast.
onSuccess: (res) => {
if (res && 'success' in res && !res.success) {
notifyError('toast.scout.deleteError', new Error(res.error ?? 'Delete failed'));
return;
}
notify('warning', 'toast.scout.deleted');
void utils.scout.local.list.invalidate();
void utils.scout.cloud.list.invalidate();
},
onError: (err) => notifyError('toast.scout.deleteError', err),
});
}
function handleToggleEnabled(id: string, type: 'local' | 'cloud', enabled: boolean) {
if (type === 'local') {
updateLocalMutation.mutate({ id, enabled }, {
onSuccess: () => void utils.scout.local.list.invalidate(),
onError: (err) => notifyError('toast.scout.updateError', err),
});
} else {
updateCloudMutation.mutate({ id, enabled }, {
onSuccess: () => void utils.scout.cloud.list.invalidate(),
onError: (err) => notifyError('toast.scout.updateError', err),
});
}
}
function handleRunNow(id: string) {
const promise = runNowMutation.mutateAsync({ id });
notifyPromise(promise, { loading: 'toast.scout.runStarted', success: 'toast.scout.runStarted', error: 'toast.scout.runError' });
}
return (
<div className="flex flex-col gap-8">
{/* Empty first-run state */}
{!hasScouts && !showTemplatePicker && (
<div className="py-4 text-center">
<div className="size-11 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto mb-4">
<Bot className="size-5 text-primary" />
</div>
<h2 className="text-base font-semibold">{t('scouts.noScoutsYet')}</h2>
<p className="text-sm text-muted-foreground max-w-md mx-auto mt-1.5">
{t('scouts.noScoutsDescription')}
</p>
<Button size="sm" className="mt-5" onClick={() => setShowTemplatePicker(true)}>
<Plus className="size-3.5 mr-1.5" />
{t('scouts.createFirstScout')}
</Button>
</div>
)}
{/* Existing configured scouts */}
{hasScouts && !showTemplatePicker && (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">{t('scouts.yourScouts')}</h2>
<Button size="sm" variant="outline" onClick={() => setShowTemplatePicker(prev => !prev)}>
<Plus className="size-3.5 mr-1.5" />
{t('scouts.createScout')}
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2">
{allScouts.map((scout) => (
<ScoutRow
key={scout.id}
scout={scout}
expanded={expandedScout === scout.id}
onToggleExpand={() => setExpandedScout(prev => prev === scout.id ? null : scout.id)}
onToggleEnabled={(enabled) => handleToggleEnabled(scout.id, scout.scoutType, enabled)}
onDelete={() => handleDelete(scout.id, scout.scoutType)}
onRunNow={() => handleRunNow(scout.id)}
onOpenJourney={() => setJourneyScout({
id: scout.id,
type: scout.scoutType,
name: scout.name,
currentConfig: scout.scoutType === 'local' ? (scout as LocalScoutConfig).agentConfig ?? null : null,
dataTypes: scout.dataTypes,
directory: scout.scoutType === 'local' ? (scout as LocalScoutConfig).directory : undefined,
})}
/>
))}
</div>
</div>
)}
{/* Backend templates picker */}
{showTemplatePicker && (
<InlineScoutCreationStepper
catalog={catalogQuery.data ?? []}
isLoadingCatalog={catalogQuery.isPending}
onCancel={() => setShowTemplatePicker(false)}
onCreated={() => {
setShowTemplatePicker(false);
void utils.scout.local.list.invalidate();
void utils.scout.cloud.list.invalidate();
}}
/>
)}
{/* Chatbot Journey dialog */}
{journeyScout && (
<JourneyDialog
agentType={journeyScout.type}
agentName={journeyScout.name}
currentConfig={journeyScout.currentConfig}
dataTypes={journeyScout.dataTypes}
directory={journeyScout.directory}
onClose={() => setJourneyScout(null)}
onSaved={(agentConfig) => {
const local = localScouts.find(a => a.id === journeyScout.id);
if (local) {
updateLocalMutation.mutate({ id: journeyScout.id, agentConfig }, {
onSuccess: () => {
void utils.scout.local.list.invalidate();
setJourneyScout(null);
},
});
} else {
setJourneyScout(null);
}
}}
/>
)}
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { FolderOpen, Mail, MessageSquare, Cloud, ArrowRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import type { AgentCatalogItem } from '../../../../shared/api-types';
import type { AgentCatalogItem } from '../../../shared/api-types';
export function TemplateSelectCard({
item,
@@ -11,6 +12,8 @@ export function TemplateSelectCard({
selected: boolean;
onSelect: () => void;
}) {
const { t } = useTranslation();
const comingSoon = item.type === 'teams' || item.type === 'outlook';
const TYPE_CONFIG = {
local_directory: { label: 'Local files', icon: <FolderOpen className="w-3.5 h-3.5" />, className: 'bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-300' },
gmail: { label: 'Gmail', icon: <Mail className="w-3.5 h-3.5" />, className: 'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300' },
@@ -22,9 +25,10 @@ export function TemplateSelectCard({
return (
<div
role="button"
tabIndex={0}
onClick={onSelect}
tabIndex={comingSoon ? -1 : 0}
onClick={comingSoon ? undefined : onSelect}
onKeyDown={(e) => {
if (comingSoon) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect();
@@ -37,6 +41,7 @@ export function TemplateSelectCard({
selected
? 'border-border bg-background'
: 'border-border/60 bg-background hover:border-border',
comingSoon && 'opacity-50 pointer-events-none',
)}
>
<div>
@@ -46,6 +51,7 @@ export function TemplateSelectCard({
{badge.icon}
{badge.label}
</span>
{comingSoon && <span className="text-[10px] uppercase tracking-wide text-muted-foreground">{t('scouts.comingSoon')}</span>}
</div>
{/* Title */}

View File

@@ -1,7 +1,7 @@
import { ListTodo, FileText, CalendarDays, Layers, type LucideIcon } from 'lucide-react';
import { User, Brain, Shield, CreditCard, Palette, Bot } from 'lucide-react';
export type SectionId = 'profile' | 'account' | 'billing' | 'appearance' | 'agents' | 'memory';
export type SectionId = 'profile' | 'account' | 'billing' | 'appearance' | 'scouts' | 'memory';
export const SECTIONS: { id: SectionId; labelKey: string; icon: LucideIcon }[] = [
{ id: 'profile', labelKey: 'settings.profile', icon: User },
@@ -9,7 +9,7 @@ export const SECTIONS: { id: SectionId; labelKey: string; icon: LucideIcon }[] =
{ id: 'account', labelKey: 'settings.account', icon: Shield },
{ id: 'billing', labelKey: 'settings.billing', icon: CreditCard },
{ id: 'appearance', labelKey: 'settings.appearance', icon: Palette },
{ id: 'agents', labelKey: 'settings.agents', icon: Bot },
{ id: 'scouts', labelKey: 'settings.scouts', icon: Bot },
];
export const SCHEDULE_OPTIONS = [
@@ -30,7 +30,7 @@ export const DATA_TYPE_CONFIG = [
] as const;
/** Mirrors LocalAgentLocalConfig from electron-store (tRPC infers it). */
export interface LocalAgentConfig {
export interface LocalScoutConfig {
id: string;
name: string;
directory: string;

View File

@@ -0,0 +1,49 @@
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
function initials(name: string): string {
return name
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase() ?? '')
.join('');
}
export function AssigneeStack({
assignees,
className,
}: {
assignees: string[];
className?: string;
}) {
if (assignees.length === 0) {
return <span className="text-muted-foreground text-sm"></span>;
}
const visible = assignees.slice(0, 2);
const overflow = assignees.length - visible.length;
return (
<Tooltip>
<TooltipTrigger asChild>
<div className={cn('flex items-center', className)}>
{visible.map((name, i) => (
<span
key={name}
className={cn(
'flex h-6 w-6 items-center justify-center rounded-full bg-muted text-[10px] font-medium ring-2 ring-background',
i > 0 && '-ml-2',
)}
>
{initials(name)}
</span>
))}
{overflow > 0 && (
<span className="-ml-2 flex h-6 min-w-6 items-center justify-center rounded-full bg-muted px-1.5 text-[10px] font-medium ring-2 ring-background">
+{overflow}
</span>
)}
</div>
</TooltipTrigger>
<TooltipContent>{assignees.join(', ')}</TooltipContent>
</Tooltip>
);
}

View File

@@ -1,97 +1,19 @@
import { useState, useEffect } from 'react';
import { TZDate } from 'react-day-picker';
import { Calendar as CalendarIcon, X, UserPlus, Check } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { useFormatPrefs, formatDate } from '@/lib/date';
import { useNotify } from '@/hooks/useNotify';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import type { TaskItem } from './TaskRow';
import { TaskFormDialog, type TaskFormValues } from './TaskFormDialog';
import type { TaskItem } from './task-types';
import { parseAssignees } from './task-utils';
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
function parseAssigneesLocal(raw: string | null): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) return parsed.filter((n): n is string => typeof n === 'string');
} catch { /* plain string fallback */ }
return [raw];
}
interface EditTaskDialogProps {
interface Props {
task: TaskItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function EditTaskDialog({ task, open, onOpenChange }: EditTaskDialogProps) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [priority, setPriority] = useState('medium');
const [status, setStatus] = useState('todo');
const prefs = useFormatPrefs();
const timezone = prefs.timezone;
const [dueDate, setDueDate] = useState<TZDate | undefined>();
const [dueHour, setDueHour] = useState('');
const [dueMinute, setDueMinute] = useState('');
const [projectId, setProjectId] = useState('');
const [assignees, setAssignees] = useState<string[]>([]);
const [assigneeInput, setAssigneeInput] = useState('');
const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false);
// Pre-fill fields whenever the task changes
useEffect(() => {
if (!task) return;
setTitle(task.title);
setDescription(task.description ?? '');
setPriority(task.priority ?? 'medium');
setStatus(task.status ?? 'todo');
if (task.dueDate) {
const d = new TZDate(task.dueDate, timezone);
setDueDate(d);
setDueHour(String(d.getHours()).padStart(2, '0'));
setDueMinute(String(d.getMinutes()).padStart(2, '0'));
} else {
setDueDate(undefined);
setDueHour('');
setDueMinute('');
}
setProjectId(task.projectId ?? '');
setAssignees(parseAssigneesLocal(task.assignee));
setAssigneeInput('');
setAssigneePopoverOpen(false);
}, [task]);
const { data: projectsList } = trpc.projects.listAll.useQuery();
const { data: knownAssignees = [] } = trpc.tasks.listAssignees.useQuery();
export function EditTaskDialog({ task, open, onOpenChange }: Props) {
const utils = trpc.useUtils();
const { notify, notifyError } = useNotify();
const updateTask = trpc.tasks.update.useMutation({
const update = trpc.tasks.update.useMutation({
onSuccess: () => {
notify('success', 'toast.task.updated');
void utils.tasks.list.invalidate();
@@ -100,291 +22,41 @@ export function EditTaskDialog({ task, open, onOpenChange }: EditTaskDialogProps
onError: (err) => notifyError('toast.task.updateError', err),
});
function addNewAssignee() {
const name = assigneeInput.trim();
if (!name || assignees.includes(name)) return;
setAssignees((prev) => [...prev, name]);
setAssigneeInput('');
}
if (!task) return null;
const taskId = task.id;
function toggleAssignee(name: string) {
setAssignees((prev) =>
prev.includes(name) ? prev.filter((a) => a !== name) : [...prev, name],
);
}
function removeAssignee(name: string) {
setAssignees((prev) => prev.filter((a) => a !== name));
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!task || !title.trim()) return;
let resolvedDueDate: number | undefined;
if (dueDate) {
const h = dueHour !== '' ? parseInt(dueHour, 10) : 0;
const m = dueMinute !== '' ? parseInt(dueMinute, 10) : 0;
const tzDate = new TZDate(
dueDate.getFullYear(),
dueDate.getMonth(),
dueDate.getDate(),
h, m, 0, 0,
timezone,
);
resolvedDueDate = tzDate.getTime();
}
updateTask.mutate({
id: task.id,
title: title.trim(),
description: description.trim() || undefined,
priority,
status,
dueDate: resolvedDueDate,
projectId: projectId || undefined,
assignees: assignees.length ? assignees : undefined,
function handleSubmit(values: TaskFormValues) {
update.mutate({
id: taskId,
title: values.title,
description: values.description || undefined,
priority: values.priority,
status: values.status,
dueDate: values.dueDate ?? undefined,
projectId: values.projectId ?? undefined,
assignees: values.assignees,
estimate: values.estimate,
});
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[560px]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>Edit Task</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{/* Title */}
<Input
placeholder="Task title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
autoFocus
/>
{/* Description */}
<Textarea
placeholder="Description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="min-h-20"
/>
{/* Priority */}
<Select value={priority} onValueChange={setPriority}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="high">High</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="low">Low</SelectItem>
</SelectContent>
</Select>
{/* Status */}
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todo">To Do</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="done">Completed</SelectItem>
</SelectContent>
</Select>
{/* Due Date + Time */}
<div className="flex flex-col gap-2">
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className={cn(
'justify-start text-left font-normal',
!dueDate && 'text-muted-foreground',
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dueDate
? `${formatDate(dueDate.getTime(), prefs)}${dueHour !== '' && dueMinute !== '' ? ` ${dueHour}:${dueMinute}` : ''}`
: 'Pick a due date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dueDate}
onSelect={(d) => setDueDate(d as TZDate | undefined)}
timeZone={timezone}
/>
<div className="border-t px-3 py-2 flex flex-col gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">Time (optional, 24h)</label>
<div className="flex items-center gap-1.5">
<Select value={dueHour} onValueChange={setDueHour}>
<SelectTrigger className="h-8 w-20 text-sm">
<SelectValue placeholder="HH" />
</SelectTrigger>
<SelectContent>
{HOURS.map((h) => (
<SelectItem key={h} value={h}>{h}</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-sm">:</span>
<Select value={dueMinute} onValueChange={setDueMinute}>
<SelectTrigger className="h-8 w-20 text-sm">
<SelectValue placeholder="MM" />
</SelectTrigger>
<SelectContent>
{MINUTES.map((m) => (
<SelectItem key={m} value={m}>{m}</SelectItem>
))}
</SelectContent>
</Select>
{(dueHour !== '' || dueMinute !== '') && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs"
onClick={() => { setDueHour(''); setDueMinute(''); }}
>
Clear
</Button>
)}
</div>
</div>
</div>
</PopoverContent>
</Popover>
{dueDate && dueHour !== '' && dueMinute !== '' && (
<p className="text-xs text-muted-foreground pl-1">
Due: {formatDate(dueDate.getTime(), prefs)} at {dueHour}:{dueMinute}
</p>
)}
</div>
{/* Project */}
<Select
value={projectId || 'none'}
onValueChange={(v) => setProjectId(v === 'none' ? '' : v)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Project (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No project</SelectItem>
{projectsList?.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Assignees */}
<div className="flex flex-col gap-2">
{assignees.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{assignees.map((name) => (
<Badge key={name} variant="secondary" className="gap-1 pr-1">
{name}
<button
type="button"
onClick={() => removeAssignee(name)}
className="ml-0.5 rounded-sm opacity-70 hover:opacity-100"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
<Popover open={assigneePopoverOpen} onOpenChange={setAssigneePopoverOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className={cn(
'justify-start font-normal',
assignees.length === 0 && 'text-muted-foreground',
)}
>
<UserPlus className="mr-2 h-4 w-4" />
{assignees.length > 0
? `${assignees.length} assignee${assignees.length > 1 ? 's' : ''}`
: 'Add assignees'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-2" align="start">
{knownAssignees.length > 0 && (
<ScrollArea className="max-h-36 mb-2">
<div className="flex flex-col gap-0.5">
{knownAssignees.map((name) => (
<Button
key={name}
type="button"
variant="ghost"
size="sm"
className="justify-start h-8 px-2"
onClick={() => toggleAssignee(name)}
>
{assignees.includes(name) ? (
<Check className="h-3 w-3 mr-2 text-primary shrink-0" />
) : (
<span className="w-5 shrink-0" />
)}
<span className="truncate">{name}</span>
</Button>
))}
</div>
</ScrollArea>
)}
{knownAssignees.length === 0 && (
<p className="text-xs text-muted-foreground px-2 py-1 mb-2">No existing assignees</p>
)}
<Separator className="mb-2" />
<div className="flex gap-2">
<Input
placeholder="New name…"
value={assigneeInput}
onChange={(e) => setAssigneeInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addNewAssignee();
}
}}
className="h-8 text-sm"
/>
<Button
type="button"
size="sm"
onClick={addNewAssignee}
disabled={!assigneeInput.trim()}
>
Add
</Button>
</div>
</PopoverContent>
</Popover>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!title.trim() || updateTask.isPending}>
{updateTask.isPending ? 'Saving…' : 'Save Changes'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<TaskFormDialog
open={open}
onOpenChange={onOpenChange}
mode="edit"
taskId={taskId}
initialValues={{
title: task.title,
description: task.description ?? '',
priority: task.priority ?? 'medium',
status: task.status ?? 'todo',
dueDate: task.dueDate ?? null,
projectId: task.projectId ?? null,
assignees: parseAssignees(task.assignee),
estimate: task.estimate ?? null,
}}
onSubmit={handleSubmit}
isSubmitting={update.isPending}
/>
);
}

View File

@@ -0,0 +1,257 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
const NO_CLIENT = '__no_client__';
interface InlineProjectFormProps {
onCancel: () => void;
onCreated: (projectId: string) => void;
}
export function InlineProjectForm({ onCancel, onCreated }: InlineProjectFormProps) {
const { t } = useTranslation();
const { notify, notifyError } = useNotify();
const [newProjectName, setNewProjectName] = useState('');
const [newProjectClientId, setNewProjectClientId] = useState(NO_CLIENT);
const [newProjectSubClientId, setNewProjectSubClientId] = useState(NO_CLIENT);
const [creatingClient, setCreatingClient] = useState(false);
const [newClientName, setNewClientName] = useState('');
const [creatingSubClient, setCreatingSubClient] = useState(false);
const [newSubClientName, setNewSubClientName] = useState('');
const { data: clientList = [] } = trpc.clients.list.useQuery();
const utils = trpc.useUtils();
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) ?? [];
arr.push(c);
m.set(c.parentId, arr);
}
}
return m;
}, [clientList]);
const createClientMutation = trpc.clients.create.useMutation({
onSuccess: () => {
notify('success', 'toast.client.created');
void utils.clients.list.invalidate();
},
});
const createProjectMutation = trpc.projects.create.useMutation({
onSuccess: () => void utils.projects.listAll.invalidate(),
});
const isSubmitting = createClientMutation.isPending || createProjectMutation.isPending;
async function handleCreate() {
if (!newProjectName.trim()) return;
try {
let resolvedClientId: string | undefined;
if (creatingClient && newClientName.trim()) {
const r = await createClientMutation.mutateAsync({ name: newClientName.trim() });
resolvedClientId = r.id;
if (creatingSubClient && newSubClientName.trim()) {
const sr = await createClientMutation.mutateAsync({
name: newSubClientName.trim(),
parentId: resolvedClientId,
});
resolvedClientId = sr.id;
}
} else if (newProjectClientId !== NO_CLIENT) {
if (creatingSubClient && newSubClientName.trim()) {
const sr = await createClientMutation.mutateAsync({
name: newSubClientName.trim(),
parentId: newProjectClientId,
});
resolvedClientId = sr.id;
} else if (newProjectSubClientId !== NO_CLIENT) {
resolvedClientId = newProjectSubClientId;
} else {
resolvedClientId = newProjectClientId;
}
}
const r = await createProjectMutation.mutateAsync({
name: newProjectName.trim(),
clientId: resolvedClientId,
});
onCreated(r.id);
} catch (err) {
notifyError('toast.project.createError', err as Error);
}
}
return (
<div className="flex flex-col gap-3 p-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t('projects.newProject')}</span>
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
{t('common.cancel')}
</Button>
</div>
{/* Project name */}
<Input
placeholder={t('projects.projectNamePlaceholder')}
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
autoFocus
className="h-8 text-sm"
/>
{/* Client selection */}
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">
{t('projects.clientOptional')} <span className="opacity-60">{t('projects.clientOptionalHint')}</span>
</label>
{creatingClient ? (
<div className="flex items-center gap-1.5">
<Input
placeholder={t('projects.newClientName')}
value={newClientName}
onChange={(e) => setNewClientName(e.target.value)}
className="flex-1 h-8 text-sm"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setCreatingClient(false);
setNewClientName('');
setCreatingSubClient(false);
setNewSubClientName('');
}}
>
{t('common.cancel')}
</Button>
</div>
) : (
<div className="flex items-center gap-1.5">
<Select
value={newProjectClientId}
onValueChange={(v) => {
setNewProjectClientId(v);
setNewProjectSubClientId(NO_CLIENT);
setCreatingSubClient(false);
setNewSubClientName('');
}}
>
<SelectTrigger className="flex-1 h-8 text-sm">
<SelectValue placeholder={t('projects.selectClient')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_CLIENT}>{t('projects.noneInternal')}</SelectItem>
{topLevelClients.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setCreatingClient(true)}
>
<Plus className="size-3.5 mr-1" />{t('projects.new')}
</Button>
</div>
)}
</div>
{/* Sub-client selection */}
{(newProjectClientId !== NO_CLIENT || (creatingClient && newClientName.trim())) && (
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">
{t('projects.subClientOptional')} <span className="opacity-60">{t('projects.clientOptionalHint')}</span>
</label>
{creatingSubClient ? (
<div className="flex items-center gap-1.5">
<Input
placeholder={t('projects.newSubClientName')}
value={newSubClientName}
onChange={(e) => setNewSubClientName(e.target.value)}
className="flex-1 h-8 text-sm"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setCreatingSubClient(false);
setNewSubClientName('');
}}
>
{t('common.cancel')}
</Button>
</div>
) : creatingClient ? (
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
onClick={() => setCreatingSubClient(true)}
>
<Plus className="size-3.5 mr-1" />{t('projects.newSubClient')}
</Button>
) : (
<div className="flex items-center gap-1.5">
<Select
value={newProjectSubClientId}
onValueChange={setNewProjectSubClientId}
>
<SelectTrigger className="flex-1 h-8 text-sm">
<SelectValue placeholder={t('projects.selectSubClient')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_CLIENT}>{t('projects.none')}</SelectItem>
{(subClientsByParent.get(newProjectClientId) ?? []).map((sc) => (
<SelectItem key={sc.id} value={sc.id}>{sc.name}</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setCreatingSubClient(true)}
>
<Plus className="size-3.5 mr-1" />{t('projects.new')}
</Button>
</div>
)}
</div>
)}
<div className="flex justify-end">
<Button
type="button"
size="sm"
onClick={handleCreate}
disabled={!newProjectName.trim() || isSubmitting}
>
{isSubmitting ? t('common.creating') : t('projects.createProject')}
</Button>
</div>
</div>
);
}

View File

@@ -1,633 +1,50 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { TZDate } from 'react-day-picker';
import { Calendar as CalendarIcon, X, UserPlus, Check, Plus } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { useFormatPrefs, formatDate } from '@/lib/date';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import { TaskFormDialog, type TaskFormValues } from './TaskFormDialog';
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
const MINUTES = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'];
const NO_CLIENT = '__no_client__';
interface NewTaskDialogProps {
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
defaultProjectId?: string;
defaultStatus?: string;
}
export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultStatus }: NewTaskDialogProps) {
const { t } = useTranslation();
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [priority, setPriority] = useState('medium');
const [status, setStatus] = useState(defaultStatus ?? 'todo');
const [dueDate, setDueDate] = useState<TZDate | undefined>();
const [dueHour, setDueHour] = useState('');
const [dueMinute, setDueMinute] = useState('');
const prefs = useFormatPrefs();
const timezone = prefs.timezone;
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
// Multi-assignee state
const [assignees, setAssignees] = useState<string[]>([]);
const [assigneeInput, setAssigneeInput] = useState('');
const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false);
// Inline project creation state
const [creatingProject, setCreatingProject] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
const [newProjectClientId, setNewProjectClientId] = useState(NO_CLIENT);
const [newProjectSubClientId, setNewProjectSubClientId] = useState(NO_CLIENT);
const [creatingClient, setCreatingClient] = useState(false);
const [newClientName, setNewClientName] = useState('');
const [creatingSubClient, setCreatingSubClient] = useState(false);
const [newSubClientName, setNewSubClientName] = useState('');
const { data: projectsList } = trpc.projects.listAll.useQuery();
const { data: clientList = [] } = trpc.clients.list.useQuery();
const { data: knownAssignees = [] } = trpc.tasks.listAssignees.useQuery();
export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultStatus }: Props) {
const utils = trpc.useUtils();
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) ?? [];
arr.push(c);
m.set(c.parentId, arr);
}
}
return m;
}, [clientList]);
const { notify, notifyError } = useNotify();
const createClientMutation = trpc.clients.create.useMutation({
onSuccess: () => {
notify('success', 'toast.client.created');
void utils.clients.list.invalidate();
},
});
const createProjectMutation = trpc.projects.create.useMutation({
onSuccess: () => void utils.projects.listAll.invalidate(),
});
const createTask = trpc.tasks.create.useMutation({
const create = trpc.tasks.create.useMutation({
onSuccess: () => {
notify('success', 'toast.task.created');
void utils.tasks.list.invalidate();
resetAndClose();
onOpenChange(false);
},
onError: (err) => notifyError('toast.task.createError', err),
});
function resetAndClose() {
setTitle('');
setDescription('');
setPriority('medium');
setStatus(defaultStatus ?? 'todo');
setDueDate(undefined);
setDueHour('');
setDueMinute('');
setProjectId(defaultProjectId ?? '');
setAssignees([]);
setAssigneeInput('');
setAssigneePopoverOpen(false);
resetProjectCreation();
onOpenChange(false);
}
function resetProjectCreation() {
setCreatingProject(false);
setNewProjectName('');
setNewProjectClientId(NO_CLIENT);
setNewProjectSubClientId(NO_CLIENT);
setCreatingClient(false);
setNewClientName('');
setCreatingSubClient(false);
setNewSubClientName('');
}
function addNewAssignee() {
const name = assigneeInput.trim();
if (!name || assignees.includes(name)) return;
setAssignees((prev) => [...prev, name]);
setAssigneeInput('');
}
function toggleAssignee(name: string) {
setAssignees((prev) =>
prev.includes(name) ? prev.filter((a) => a !== name) : [...prev, name],
);
}
function removeAssignee(name: string) {
setAssignees((prev) => prev.filter((a) => a !== name));
}
async function handleCreateInlineProject(): Promise<string | undefined> {
let resolvedClientId: string | undefined;
if (creatingClient && newClientName.trim()) {
const r = await createClientMutation.mutateAsync({ name: newClientName.trim() });
resolvedClientId = r.id;
if (creatingSubClient && newSubClientName.trim()) {
const sr = await createClientMutation.mutateAsync({
name: newSubClientName.trim(),
parentId: resolvedClientId,
});
resolvedClientId = sr.id;
}
} else if (newProjectClientId !== NO_CLIENT) {
if (creatingSubClient && newSubClientName.trim()) {
const sr = await createClientMutation.mutateAsync({
name: newSubClientName.trim(),
parentId: newProjectClientId,
});
resolvedClientId = sr.id;
} else if (newProjectSubClientId !== NO_CLIENT) {
resolvedClientId = newProjectSubClientId;
} else {
resolvedClientId = newProjectClientId;
}
}
const r = await createProjectMutation.mutateAsync({
name: newProjectName.trim(),
clientId: resolvedClientId,
});
return r.id;
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!title.trim()) return;
// Resolve dueDate + optional time in the selected timezone
let resolvedDueDate: number | undefined;
if (dueDate) {
const h = dueHour !== '' ? parseInt(dueHour, 10) : 0;
const m = dueMinute !== '' ? parseInt(dueMinute, 10) : 0;
const tzDate = new TZDate(
dueDate.getFullYear(),
dueDate.getMonth(),
dueDate.getDate(),
h, m, 0, 0,
timezone,
);
resolvedDueDate = tzDate.getTime();
}
// If creating a new project inline, do that first
let resolvedProjectId = projectId || undefined;
if (creatingProject && newProjectName.trim()) {
resolvedProjectId = await handleCreateInlineProject();
}
createTask.mutate({
title: title.trim(),
description: description.trim() || undefined,
priority,
status,
dueDate: resolvedDueDate,
projectId: resolvedProjectId,
assignees: assignees.length ? assignees : undefined,
function handleSubmit(values: TaskFormValues) {
create.mutate({
title: values.title,
description: values.description || undefined,
priority: values.priority,
status: values.status,
dueDate: values.dueDate ?? undefined,
projectId: values.projectId ?? undefined,
assignees: values.assignees.length ? values.assignees : undefined,
estimate: values.estimate ?? undefined,
});
}
const isSubmitting =
createTask.isPending ||
createClientMutation.isPending ||
createProjectMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[560px]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>{t('tasks.newTask')}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{/* Title */}
<Input
placeholder={t('tasks.taskTitle')}
value={title}
onChange={(e) => setTitle(e.target.value)}
required
autoFocus
/>
{/* Description */}
<Textarea
placeholder={t('tasks.descriptionOptional')}
value={description}
onChange={(e) => setDescription(e.target.value)}
className="min-h-20"
/>
{/* Priority */}
<Select value={priority} onValueChange={setPriority}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="high">{t('tasks.high')}</SelectItem>
<SelectItem value="medium">{t('tasks.medium')}</SelectItem>
<SelectItem value="low">{t('tasks.low')}</SelectItem>
</SelectContent>
</Select>
{/* Status */}
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todo">{t('tasks.toDo')}</SelectItem>
<SelectItem value="in_progress">{t('tasks.inProgress')}</SelectItem>
<SelectItem value="done">{t('tasks.completed')}</SelectItem>
</SelectContent>
</Select>
{/* Due Date + Time */}
<div className="flex flex-col gap-2">
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className={cn(
'justify-start text-left font-normal',
!dueDate && 'text-muted-foreground',
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dueDate
? `${formatDate(dueDate.getTime(), prefs)}${dueHour !== '' && dueMinute !== '' ? ` ${dueHour}:${dueMinute}` : ''}`
: t('tasks.pickDueDate')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dueDate}
onSelect={(d) => setDueDate(d as TZDate | undefined)}
timeZone={timezone}
/>
<div className="border-t px-3 py-2 flex flex-col gap-2">
{/* Time row */}
<div>
<label className="text-xs text-muted-foreground mb-1 block">Time (optional, 24h)</label>
<div className="flex items-center gap-1.5">
<Select value={dueHour} onValueChange={setDueHour}>
<SelectTrigger className="h-8 w-20 text-sm">
<SelectValue placeholder="HH" />
</SelectTrigger>
<SelectContent>
{HOURS.map((h) => (
<SelectItem key={h} value={h}>{h}</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-sm">:</span>
<Select value={dueMinute} onValueChange={setDueMinute}>
<SelectTrigger className="h-8 w-20 text-sm">
<SelectValue placeholder="MM" />
</SelectTrigger>
<SelectContent>
{MINUTES.map((m) => (
<SelectItem key={m} value={m}>{m}</SelectItem>
))}
</SelectContent>
</Select>
{(dueHour !== '' || dueMinute !== '') && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs"
onClick={() => { setDueHour(''); setDueMinute(''); }}
>
Clear
</Button>
)}
</div>
</div>
</div>
</PopoverContent>
</Popover>
{dueDate && dueHour !== '' && dueMinute !== '' && (
<p className="text-xs text-muted-foreground pl-1">
Due: {formatDate(dueDate.getTime(), prefs)} at {dueHour}:{dueMinute}
</p>
)}
</div>
{/* Project */}
{!creatingProject ? (
<div className="flex items-center gap-2">
<Select
value={projectId || 'none'}
onValueChange={(v) => setProjectId(v === 'none' ? '' : v)}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Project (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No project</SelectItem>
{projectsList?.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setCreatingProject(true)}
>
<Plus className="size-3.5 mr-1" />New
</Button>
</div>
) : (
<div className="flex flex-col gap-3 rounded-md border p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">New Project</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={resetProjectCreation}
>
Cancel
</Button>
</div>
{/* Project name */}
<Input
placeholder="Project name"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
autoFocus
/>
{/* Client selection */}
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">
Client <span className="opacity-60">(optional)</span>
</label>
{creatingClient ? (
<div className="flex items-center gap-2">
<Input
placeholder="New client name"
value={newClientName}
onChange={(e) => setNewClientName(e.target.value)}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setCreatingClient(false);
setNewClientName('');
setCreatingSubClient(false);
setNewSubClientName('');
}}
>
Cancel
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Select
value={newProjectClientId}
onValueChange={(v) => {
setNewProjectClientId(v);
setNewProjectSubClientId(NO_CLIENT);
setCreatingSubClient(false);
setNewSubClientName('');
}}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Select a client" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_CLIENT}>None (Internal)</SelectItem>
{topLevelClients.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setCreatingClient(true)}
>
<Plus className="size-3.5 mr-1" />New
</Button>
</div>
)}
</div>
{/* Sub-client selection — only when a client is selected or being created */}
{(newProjectClientId !== NO_CLIENT || (creatingClient && newClientName.trim())) && (
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">
Sub-client <span className="opacity-60">(optional)</span>
</label>
{creatingSubClient ? (
<div className="flex items-center gap-2">
<Input
placeholder="New sub-client name"
value={newSubClientName}
onChange={(e) => setNewSubClientName(e.target.value)}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setCreatingSubClient(false);
setNewSubClientName('');
}}
>
Cancel
</Button>
</div>
) : creatingClient ? (
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
onClick={() => setCreatingSubClient(true)}
>
<Plus className="size-3.5 mr-1" />New Sub-client
</Button>
) : (
<div className="flex items-center gap-2">
<Select
value={newProjectSubClientId}
onValueChange={setNewProjectSubClientId}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Select a sub-client" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_CLIENT}>None</SelectItem>
{(subClientsByParent.get(newProjectClientId) ?? []).map((sc) => (
<SelectItem key={sc.id} value={sc.id}>{sc.name}</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setCreatingSubClient(true)}
>
<Plus className="size-3.5 mr-1" />New
</Button>
</div>
)}
</div>
)}
</div>
)}
{/* Assignees */}
<div className="flex flex-col gap-2">
{/* Selected assignee badges */}
{assignees.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{assignees.map((name) => (
<Badge key={name} variant="secondary" className="gap-1 pr-1">
{name}
<button
type="button"
onClick={() => removeAssignee(name)}
className="ml-0.5 rounded-sm opacity-70 hover:opacity-100"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
{/* Assignee picker popover */}
<Popover open={assigneePopoverOpen} onOpenChange={setAssigneePopoverOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className={cn(
'justify-start font-normal',
assignees.length === 0 && 'text-muted-foreground',
)}
>
<UserPlus className="mr-2 h-4 w-4" />
{assignees.length > 0
? `${assignees.length} assignee${assignees.length > 1 ? 's' : ''}`
: 'Add assignees'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-2" align="start">
{/* Known assignees list */}
{knownAssignees.length > 0 && (
<ScrollArea className="max-h-36 mb-2">
<div className="flex flex-col gap-0.5">
{knownAssignees.map((name) => (
<Button
key={name}
type="button"
variant="ghost"
size="sm"
className="justify-start h-8 px-2"
onClick={() => toggleAssignee(name)}
>
{assignees.includes(name) ? (
<Check className="h-3 w-3 mr-2 text-primary shrink-0" />
) : (
<span className="w-5 shrink-0" />
)}
<span className="truncate">{name}</span>
</Button>
))}
</div>
</ScrollArea>
)}
{knownAssignees.length === 0 && (
<p className="text-xs text-muted-foreground px-2 py-1 mb-2">No existing assignees</p>
)}
<Separator className="mb-2" />
{/* Add new assignee */}
<div className="flex gap-2">
<Input
placeholder="New name…"
value={assigneeInput}
onChange={(e) => setAssigneeInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addNewAssignee();
}
}}
className="h-8 text-sm"
/>
<Button
type="button"
size="sm"
onClick={addNewAssignee}
disabled={!assigneeInput.trim()}
>
Add
</Button>
</div>
</PopoverContent>
</Popover>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={resetAndClose}>
Cancel
</Button>
<Button type="submit" disabled={!title.trim() || isSubmitting}>
{isSubmitting ? 'Creating…' : 'Create Task'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<TaskFormDialog
open={open}
onOpenChange={onOpenChange}
mode="create"
initialValues={{
projectId: defaultProjectId ?? null,
status: defaultStatus ?? 'todo',
}}
onSubmit={handleSubmit}
isSubmitting={create.isPending}
/>
);
}

View File

@@ -0,0 +1,40 @@
import { forwardRef } from 'react';
import { cn } from '@/lib/utils';
export interface PropertyPillProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
icon: React.ReactNode;
label: string;
value?: string | null;
empty?: boolean;
}
export const PropertyPill = forwardRef<HTMLButtonElement, PropertyPillProps>(
({ icon, label, value, empty, className, ...rest }, ref) => (
<button
ref={ref}
type="button"
data-empty={empty ? 'true' : undefined}
className={cn(
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:border-ring',
empty
? 'border border-dashed border-border text-muted-foreground hover:text-foreground hover:border-foreground/50'
: 'border border-border/60 bg-background/60 text-foreground hover:border-ring/40',
className,
)}
{...rest}
>
<span className="flex items-center">{icon}</span>
{empty ? (
<span>{label}</span>
) : (
<>
<span className="text-muted-foreground">{label}:</span>
<span>{value}</span>
</>
)}
</button>
),
);
PropertyPill.displayName = 'PropertyPill';

View File

@@ -0,0 +1,39 @@
import { useTranslation } from 'react-i18next';
import { Circle, Clock, CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';
const STATUS_CONFIG = {
todo: {
icon: Circle,
className: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
labelKey: 'tasks.toDo',
},
in_progress: {
icon: Clock,
className: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
labelKey: 'tasks.inProgress',
},
done: {
icon: CheckCircle2,
className: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
labelKey: 'tasks.done',
},
} as const;
export function StatusBadge({ status, className }: { status: string | null; className?: string }) {
const { t } = useTranslation();
const conf = STATUS_CONFIG[(status ?? 'todo') as keyof typeof STATUS_CONFIG] ?? STATUS_CONFIG.todo;
const Icon = conf.icon;
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium',
conf.className,
className,
)}
>
<Icon className="h-3 w-3" />
{t(conf.labelKey)}
</span>
);
}

View File

@@ -0,0 +1,37 @@
import { Paperclip, X } from 'lucide-react';
import { cn } from '@/lib/utils';
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function TaskAttachmentChip({
filename,
sizeBytes,
onOpen,
onDelete,
}: {
filename: string;
sizeBytes: number;
onOpen: () => void;
onDelete: () => void;
}) {
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-background/60 px-2.5 py-1 text-xs',
)}
>
<button type="button" onClick={onOpen} className="flex items-center gap-1.5 hover:underline">
<Paperclip className="h-3 w-3" />
<span className="max-w-[180px] truncate">{filename}</span>
<span className="text-muted-foreground">· {formatSize(sizeBytes)}</span>
</button>
<button type="button" onClick={onDelete} className="text-muted-foreground hover:text-destructive">
<X className="h-3 w-3" />
</button>
</span>
);
}

View File

@@ -1,9 +1,8 @@
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { Calendar, User, Pencil, Trash2, Sparkles } from 'lucide-react';
import { Calendar, User, Pencil, Trash2, Sparkles, RefreshCw, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
@@ -17,37 +16,21 @@ import {
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
ContextMenuSub,
ContextMenuSubTrigger,
ContextMenuSubContent,
} from '@/components/ui/context-menu';
import { PriorityBadge } from './PriorityBadge';
import { type TaskItem } from './TaskRow';
import { StatusBadge } from './StatusBadge';
import { type TaskItem } from './task-types';
import { useFormatPrefs, formatDueDate } from '@/lib/date';
import { parseAssignees } from './task-utils';
function StatusBadge({ status }: { status: string | null }) {
const { t } = useTranslation();
if (!status) return null;
const label =
status === 'todo' ? t('tasks.toDo') :
status === 'in_progress' ? t('tasks.inProgress') :
status === 'done' ? t('tasks.done') : null;
if (!label) return null;
return (
<Badge
variant="outline"
className={cn(
'text-xs',
status === 'in_progress' && 'border-sky-300 dark:border-sky-800 bg-sky-50 dark:bg-sky-950/30 text-sky-700 dark:text-sky-400',
status === 'done' && 'border-green-300 dark:border-green-800 bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400',
)}
>
{label}
</Badge>
);
}
const STATUSES = ['todo', 'in_progress', 'done'] as const;
export function TaskCard({
task,
onToggle,
onStatusChange,
onEdit,
onDelete,
onClick,
@@ -55,7 +38,7 @@ export function TaskCard({
layoutId,
}: {
task: TaskItem;
onToggle: (id: string, status: string | null) => void;
onStatusChange: (id: string, status: string) => void;
onEdit?: (task: TaskItem) => void;
onDelete?: (id: string) => void;
onClick?: (task: TaskItem) => void;
@@ -66,10 +49,6 @@ export function TaskCard({
const prefs = useFormatPrefs();
const isDone = task.status === 'done';
const checkboxState: boolean | 'indeterminate' =
task.status === 'done' ? true :
task.status === 'in_progress' ? 'indeterminate' : false;
const breadcrumb: string[] = [];
if (!hideBreadcrumb) {
if (task.clientName) breadcrumb.push(task.clientName);
@@ -97,16 +76,10 @@ export function TaskCard({
)}
onClick={() => onClick?.(task)}
>
{/* Header: checkbox + title */}
<div className="flex items-start gap-3">
<Checkbox
checked={checkboxState}
onCheckedChange={() => onToggle(task.id, task.status)}
onClick={(e) => e.stopPropagation()}
className="mt-0.5 shrink-0"
/>
{/* Header: title (no checkbox) */}
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<div className={cn('flex items-center gap-1 text-sm font-medium min-w-0', isDone && 'line-through text-muted-foreground')}>
<div className={cn('flex items-center gap-1.5 text-sm font-medium min-w-0', isDone && 'line-through text-muted-foreground')}>
{task.isAiSuggested ? <Sparkles className="h-3 w-3 shrink-0 text-amber-500" /> : null}
<span className="truncate">{task.title}</span>
</div>
@@ -168,6 +141,20 @@ export function TaskCard({
<Pencil className="h-4 w-4 mr-2" />
{t('common.edit')}
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>
<RefreshCw className="h-4 w-4 mr-2" />
{t('tasks.changeStatus')}
</ContextMenuSubTrigger>
<ContextMenuSubContent>
{STATUSES.map((s) => (
<ContextMenuItem key={s} onSelect={() => onStatusChange(task.id, s)}>
{task.status === s ? <Check className="h-3 w-3 mr-2" /> : <span className="w-5" />}
{t(s === 'todo' ? 'tasks.toDo' : s === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem
onSelect={() => onDelete?.(task.id)}
className="text-destructive focus:text-destructive"

View File

@@ -1,269 +0,0 @@
import { useState } from 'react';
import {
Calendar,
User,
CircleDot,
FolderOpen,
Zap,
Pencil,
Trash2,
Send,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { PriorityBadge } from './PriorityBadge';
import { parseAssignees, type TaskItem } from './TaskRow';
import { useFormatPrefs, formatDueDate, formatRelative } from '@/lib/date';
const STATUS_CONFIG: Record<string, { label: string; className: string }> = {
todo: { label: 'To Do', className: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' },
in_progress: { label: 'In Progress', className: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' },
done: { label: 'Done', className: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' },
};
function AuthorAvatar({ name }: { name: string }) {
const initials = name
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase() ?? '')
.join('');
return (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium">
{initials}
</div>
);
}
interface TaskDetailDialogProps {
task: TaskItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onEdit: (task: TaskItem) => void;
onDelete: (id: string) => void;
}
export function TaskDetailDialog({ task, open, onOpenChange, onEdit, onDelete }: TaskDetailDialogProps) {
const [commentText, setCommentText] = useState('');
const [activeTab, setActiveTab] = useState('description');
const prefs = useFormatPrefs();
const { data: comments } = trpc.taskComments.list.useQuery(
{ taskId: task?.id ?? '' },
{ enabled: !!task },
);
const utils = trpc.useUtils();
const { notify, notifyError } = useNotify();
const addComment = trpc.taskComments.create.useMutation({
onSuccess: () => {
notify('success', 'toast.comment.created');
void utils.taskComments.list.invalidate({ taskId: task?.id ?? '' });
setCommentText('');
},
onError: (err) => notifyError('toast.comment.createError', err),
});
const deleteComment = trpc.taskComments.delete.useMutation({
onSuccess: () => {
notify('warning', 'toast.comment.deleted');
void utils.taskComments.list.invalidate({ taskId: task?.id ?? '' });
},
onError: (err) => notifyError('toast.comment.deleteError', err),
});
if (!task) return null;
const assignees = parseAssignees(task.assignee);
const statusConf = STATUS_CONFIG[task.status ?? 'todo'] ?? { label: 'To Do', className: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' };
const breadcrumb = [task.clientName, task.subClientName, task.projectName].filter(Boolean);
const handleAddComment = () => {
const text = commentText.trim();
if (!text) return;
addComment.mutate({ taskId: task.id, author: 'Me', content: text });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[620px] gap-0 p-0" aria-describedby={undefined}>
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle className="text-lg font-semibold leading-tight">{task.title}</DialogTitle>
</DialogHeader>
<Separator />
{/* Field rows */}
<div className="grid grid-cols-[120px_1fr] gap-y-3 px-6 py-4 text-sm">
{/* Assignee */}
<div className="flex items-center gap-2 text-muted-foreground">
<User className="h-4 w-4" />
Assignee
</div>
<div className="flex flex-wrap items-center gap-1.5">
{assignees.length > 0 ? (
assignees.map((name) => (
<Badge key={name} variant="secondary" className="text-xs">
{name}
</Badge>
))
) : (
<span className="text-muted-foreground">Unassigned</span>
)}
</div>
{/* Status */}
<div className="flex items-center gap-2 text-muted-foreground">
<CircleDot className="h-4 w-4" />
Status
</div>
<div>
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${statusConf.className}`}>
{statusConf.label}
</span>
</div>
{/* Due date */}
<div className="flex items-center gap-2 text-muted-foreground">
<Calendar className="h-4 w-4" />
Due date
</div>
<div>
{task.dueDate ? formatDueDate(task.dueDate, prefs) : <span className="text-muted-foreground">No due date</span>}
</div>
{/* Priority */}
<div className="flex items-center gap-2 text-muted-foreground">
<Zap className="h-4 w-4" />
Priority
</div>
<div>
<PriorityBadge priority={task.priority} />
</div>
{/* Project */}
{breadcrumb.length > 0 && (
<>
<div className="flex items-center gap-2 text-muted-foreground">
<FolderOpen className="h-4 w-4" />
Project
</div>
<div className="text-sm">{breadcrumb.join(' > ')}</div>
</>
)}
</div>
<Separator />
{/* Tabs: Description / Comment */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col">
<TabsList className="mx-6 mt-3 w-fit">
<TabsTrigger value="description">Description</TabsTrigger>
<TabsTrigger value="comment">Comment</TabsTrigger>
</TabsList>
<TabsContent value="description" className="px-6 py-4 min-h-[120px]">
{task.description ? (
<p className="text-sm whitespace-pre-wrap">{task.description}</p>
) : (
<p className="text-sm text-muted-foreground italic">No description provided.</p>
)}
</TabsContent>
<TabsContent value="comment" className="px-6 py-4 min-h-[120px] flex flex-col gap-4">
{/* Comment list */}
<ScrollArea className="max-h-[260px]">
<div className="flex flex-col gap-4">
{(!comments || comments.length === 0) ? (
<p className="text-sm text-muted-foreground italic">No comments yet.</p>
) : (
comments.map((c) => (
<div key={c.id} className="flex gap-3">
<AuthorAvatar name={c.author} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium">{c.author}</span>
<span className="text-xs text-muted-foreground">{formatRelative(c.createdAt)}</span>
</div>
<div className="rounded-lg bg-muted px-3 py-2 text-sm">
{c.content}
</div>
<div className="flex items-center gap-3 mt-1">
<button
type="button"
className="text-xs text-muted-foreground hover:text-destructive"
onClick={() => deleteComment.mutate({ id: c.id })}
>
Delete
</button>
</div>
</div>
</div>
))
)}
</div>
</ScrollArea>
{/* Add comment input */}
<form
className="flex items-center gap-2 mt-auto"
onSubmit={(e) => { e.preventDefault(); handleAddComment(); }}
>
<AuthorAvatar name="Me" />
<Input
placeholder="Add a comment..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
className="flex-1"
/>
<Button
type="submit"
size="icon"
variant="ghost"
disabled={!commentText.trim() || addComment.isPending}
>
<Send className="h-4 w-4" />
</Button>
</form>
</TabsContent>
</Tabs>
<Separator />
{/* Footer */}
<DialogFooter className="px-6 py-4">
<Button
variant="destructive"
size="sm"
onClick={() => { onDelete(task.id); onOpenChange(false); }}
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</Button>
<Button
size="sm"
onClick={() => { onEdit(task); onOpenChange(false); }}
>
<Pencil className="h-4 w-4 mr-1" />
Edit
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,278 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from '@tanstack/react-router';
import { MoreHorizontal, Pencil, Trash2, ChevronRight, Plus, X } from 'lucide-react';
import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { PriorityBadge } from './PriorityBadge';
import { StatusBadge } from './StatusBadge';
import { type TaskItem } from './task-types';
import { useFormatPrefs, formatDueDate, formatRelative } from '@/lib/date';
import { parseAssignees } from './task-utils';
import { AssigneeStack } from './AssigneeStack';
import { TaskAttachmentChip } from './TaskAttachmentChip';
import { useTaskAttachments } from './useTaskAttachments';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { ChatInputBox } from '@/components/ai/ChatInputBox';
interface Props {
task: TaskItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onEdit: (task: TaskItem) => void;
onDelete: (id: string) => void;
}
export function TaskDetailSheet({ task, open, onOpenChange, onEdit, onDelete }: Props) {
const { t } = useTranslation();
const prefs = useFormatPrefs();
const navigate = useNavigate();
const attachments = useTaskAttachments(task?.id ?? null);
const utils = trpc.useUtils();
const { notifyError } = useNotify();
const { data: comments } = trpc.taskComments.list.useQuery(
{ taskId: task?.id ?? '' },
{ enabled: !!task },
);
const { data: fresh } = trpc.tasks.byIds.useQuery(
{ ids: task ? [task.id] : [] },
{ enabled: !!task },
);
const addComment = trpc.taskComments.create.useMutation({
onSuccess: () => {
if (task) void utils.taskComments.list.invalidate({ taskId: task.id });
},
onError: (err) => notifyError('toast.comment.createError', err),
});
const deleteComment = trpc.taskComments.delete.useMutation({
onSuccess: () => task && void utils.taskComments.list.invalidate({ taskId: task.id }),
});
const updateTask = trpc.tasks.update.useMutation({
onSuccess: () => {
void utils.tasks.list.invalidate();
void utils.tasks.byIds.invalidate();
},
onError: (err) => notifyError('toast.task.updateError', err),
});
const [priorityOpen, setPriorityOpen] = useState(false);
const [statusOpen, setStatusOpen] = useState(false);
if (!task) return null;
const liveTask = (fresh?.[0] as TaskItem | undefined) ?? task;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
showCloseButton={false}
className="w-[480px] !max-w-[480px] flex flex-col p-0 gap-0 bg-card/85 backdrop-blur-xl border-border/50"
>
{/* Sticky header */}
<div className="px-6 pt-6 pb-4 border-b border-border/40 shrink-0">
<div className="flex items-center justify-between gap-2">
<button
type="button"
onClick={() => {
if (liveTask.projectId) {
onOpenChange(false);
navigate({ to: '/projects', search: { projectId: liveTask.projectId } });
}
}}
disabled={!liveTask.projectId}
className="text-xs text-muted-foreground flex items-center gap-1 min-w-0 hover:text-foreground transition-colors disabled:cursor-default disabled:hover:text-muted-foreground"
>
{liveTask.clientName && <span className="truncate">{liveTask.clientName}</span>}
{liveTask.clientName && liveTask.projectName && <ChevronRight className="h-3 w-3 shrink-0" />}
{liveTask.projectName && <span className="text-foreground font-medium truncate">{liveTask.projectName}</span>}
</button>
<div className="flex items-center gap-1 shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => onEdit(liveTask)}>
<Pencil className="h-4 w-4 mr-2" />
{t('common.edit')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onDelete(liveTask.id)} className="text-destructive focus:text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
{t('common.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<SheetClose asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<X className="h-4 w-4" />
</Button>
</SheetClose>
</div>
</div>
<div className="text-lg font-semibold leading-tight mt-1">{liveTask.title}</div>
<div className="flex items-center gap-2 mt-2">
<Popover open={priorityOpen} onOpenChange={setPriorityOpen}>
<PopoverTrigger asChild>
<button type="button" className="rounded hover:bg-accent/50 px-1 -mx-1">
<PriorityBadge priority={liveTask.priority} />
</button>
</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="start">
{(['high', 'medium', 'low'] as const).map((p) => (
<button
key={p}
type="button"
onClick={() => {
updateTask.mutate({ id: liveTask.id, priority: p });
setPriorityOpen(false);
}}
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50"
>
{t(`tasks.${p}`)}
</button>
))}
</PopoverContent>
</Popover>
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
<PopoverTrigger asChild>
<button type="button" className="rounded hover:bg-accent/50 px-1 -mx-1">
<StatusBadge status={liveTask.status} />
</button>
</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="start">
{(['todo', 'in_progress', 'done'] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => {
updateTask.mutate({ id: liveTask.id, status: s });
setStatusOpen(false);
}}
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50"
>
{t(s === 'todo' ? 'tasks.toDo' : s === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}
</button>
))}
</PopoverContent>
</Popover>
</div>
</div>
{/* Scrolling body */}
<div className="flex-1 overflow-y-auto">
<div className="mx-6 mt-4 rounded-lg border border-border/40 bg-background/40 p-4">
<div className="grid grid-cols-2 gap-x-6 gap-y-4">
<PropRow label={t('tasks.assignee')}>
<AssigneeStack assignees={parseAssignees(liveTask.assignee)} />
</PropRow>
<PropRow label={t('tasks.colDue')}>
{liveTask.dueDate ? formatDueDate(liveTask.dueDate, prefs) : <span className="text-muted-foreground"></span>}
</PropRow>
<PropRow label={t('tasks.estimate')}>
<span className="text-muted-foreground"></span>
</PropRow>
<PropRow label={t('tasks.created')}>
{liveTask.createdAt ? formatRelative(liveTask.createdAt) : <span className="text-muted-foreground"></span>}
</PropRow>
<div className="col-span-2">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2">
{t('tasks.attachments')}
</div>
<div className="flex flex-wrap items-center gap-2">
{(attachments.list.data ?? []).map((a) => (
<TaskAttachmentChip
key={a.id}
filename={a.filename}
sizeBytes={a.sizeBytes}
onOpen={() => attachments.open.mutate({ id: a.id })}
onDelete={() => attachments.remove.mutate({ id: a.id })}
/>
))}
<button
type="button"
onClick={() => attachments.addFiles()}
className="inline-flex items-center gap-1 rounded-full border border-dashed border-border px-2.5 py-1 text-xs text-muted-foreground hover:text-foreground hover:border-foreground/50"
>
<Plus className="h-3 w-3" />
{t('tasks.addFile')}
</button>
</div>
</div>
</div>
</div>
<div className="px-6 py-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2">
{t('tasks.description')}
</div>
{liveTask.description ? (
<div className="text-sm whitespace-pre-wrap">{liveTask.description}</div>
) : (
<div className="text-sm italic text-muted-foreground">{t('tasks.noDescription')}</div>
)}
</div>
<div className="h-px bg-border/40 mx-6" />
<div className="px-6 py-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2">
{t('tasks.comments')} · {comments?.length ?? 0}
</div>
<div className="flex flex-col gap-3">
{(comments ?? []).map((c) => (
<div key={c.id} className="flex gap-3">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-medium">
{c.author.split(/\s+/).slice(0, 2).map((w) => w[0]?.toUpperCase() ?? '').join('')}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium">{c.author}</span>
<span className="text-[10px] text-muted-foreground">{formatRelative(c.createdAt)}</span>
</div>
<div className="rounded-md bg-background/60 px-3 py-2 text-sm">{c.content}</div>
<button
type="button"
onClick={() => deleteComment.mutate({ id: c.id })}
className="mt-1 text-[10px] text-muted-foreground hover:text-destructive"
>
{t('common.delete')}
</button>
</div>
</div>
))}
</div>
</div>
</div>
{/* Sticky composer */}
<div className="px-6 py-3 border-t border-border/40 shrink-0">
<div className="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
cacheKey={`task-comment-${liveTask.id}`}
isStreaming={false}
variant="comment"
placeholder={t('tasks.writeComment')}
onSend={(text) => addComment.mutate({ taskId: liveTask.id, author: 'Me', content: text })}
/>
</div>
</div>
</SheetContent>
</Sheet>
);
}
function PropRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">{label}</div>
<div className="text-sm">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,627 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Folder, ArrowUp, ArrowRight, ArrowDown, Circle, Clock, CheckCircle2, Calendar as CalIcon, UserPlus, Plus, Paperclip } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { PropertyPill } from './PropertyPill';
import { InlineProjectForm } from './InlineProjectForm';
import { useFormatPrefs, formatDueDate } from '@/lib/date';
import { useTaskAttachments } from './useTaskAttachments';
import { useRovingFocus } from '@/hooks/useRovingFocus';
import { useListboxKeys } from '@/hooks/useListboxKeys';
import { DateTimeField } from '@/components/ui/datetime-field';
export type TaskFormValues = {
title: string;
description: string;
priority: string;
status: string;
dueDate: number | null;
projectId: string | null;
assignees: string[];
estimate: number | null;
};
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: 'create' | 'edit';
taskId?: string;
initialValues?: Partial<TaskFormValues>;
onSubmit: (values: TaskFormValues) => void;
isSubmitting?: boolean;
}
const DEFAULTS: TaskFormValues = {
title: '',
description: '',
priority: 'medium',
status: 'todo',
dueDate: null,
projectId: null,
assignees: [],
estimate: null,
};
// ---------------------------------------------------------------------------
// Sub-components (defined outside TaskFormDialog to avoid recreation on render)
// ---------------------------------------------------------------------------
function ProjectList({
projects,
selectedId,
onSelect,
onCreate,
onClose,
}: {
projects: { id: string; name: string }[];
selectedId: string | null;
onSelect: (id: string | null) => void;
onCreate: () => void;
onClose: () => void;
}) {
const { t } = useTranslation();
// Item layout: [0] "+ New project", [1] "No project", [2..N+1] projects
const items = [
{ kind: 'new' as const },
{ kind: 'none' as const },
...projects.map((p) => ({ kind: 'project' as const, id: p.id, name: p.name })),
];
const listbox = useListboxKeys({
itemCount: items.length,
initialIndex: selectedId
? Math.max(0, items.findIndex((it) => it.kind === 'project' && it.id === selectedId))
: 1,
onSelect: (i) => {
const it = items[i];
if (it.kind === 'new') onCreate();
else if (it.kind === 'none') onSelect(null);
else onSelect(it.id);
},
onClose,
});
useEffect(() => {
listbox.focusIndex(listbox.activeIndex);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const base =
'w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none flex items-center gap-1.5';
return (
<div className="p-1" role="listbox" aria-label={t('tasks.project')}>
{items.map((it, i) => {
const itemProps = listbox.getItemProps(i);
if (it.kind === 'new') {
return (
<button
key="new"
type="button"
{...itemProps}
role="option"
className={base + ' text-primary'}
onClick={() => onCreate()}
>
<Plus className="h-3.5 w-3.5" />
{t('projects.newProject')}
</button>
);
}
if (it.kind === 'none') {
return (
<button
key="none"
type="button"
{...itemProps}
role="option"
className={base}
onClick={() => onSelect(null)}
>
{t('tasks.noProject')}
</button>
);
}
return (
<button
key={it.id}
type="button"
{...itemProps}
role="option"
className={base}
onClick={() => onSelect(it.id)}
>
{it.name}
</button>
);
})}
</div>
);
}
function PriorityList({
value,
onSelect,
onClose,
}: {
value: string;
onSelect: (v: 'high' | 'medium' | 'low') => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const items = ['high', 'medium', 'low'] as const;
const initial = Math.max(0, items.indexOf(value as (typeof items)[number]));
const listbox = useListboxKeys({
itemCount: items.length,
initialIndex: initial,
onSelect: (i) => onSelect(items[i]),
onClose,
});
useEffect(() => {
listbox.focusIndex(listbox.activeIndex);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div role="listbox" aria-label={t('tasks.priority')} className="p-1">
{items.map((p, i) => (
<button
key={p}
type="button"
{...listbox.getItemProps(i)}
role="option"
aria-selected={value === p}
onClick={() => onSelect(p)}
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none"
>
{t(`tasks.${p}`)}
</button>
))}
</div>
);
}
function StatusList({
value,
onSelect,
onClose,
}: {
value: string;
onSelect: (v: 'todo' | 'in_progress' | 'done') => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const items = ['todo', 'in_progress', 'done'] as const;
const initial = Math.max(0, items.indexOf(value as (typeof items)[number]));
const listbox = useListboxKeys({
itemCount: items.length,
initialIndex: initial,
onSelect: (i) => onSelect(items[i]),
onClose,
});
useEffect(() => {
listbox.focusIndex(listbox.activeIndex);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div role="listbox" aria-label={t('tasks.status')} className="p-1">
{items.map((s, i) => (
<button
key={s}
type="button"
{...listbox.getItemProps(i)}
role="option"
aria-selected={value === s}
onClick={() => onSelect(s)}
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none"
>
{t(s === 'todo' ? 'tasks.toDo' : s === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}
</button>
))}
</div>
);
}
function AssigneesList({
known,
selected,
onToggle,
onClose,
newName,
onNewNameChange,
onAddNew,
}: {
known: string[];
selected: string[];
onToggle: (name: string) => void;
onClose: () => void;
newName: string;
onNewNameChange: (s: string) => void;
onAddNew: () => void;
}) {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const listbox = useListboxKeys({
itemCount: known.length,
initialIndex: 0,
onSelect: (i) => onToggle(known[i]),
onClose,
});
useEffect(() => {
if (known.length > 0) listbox.focusIndex(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="p-2" role="listbox" aria-multiselectable="true" aria-label={t('tasks.assignees')}>
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
{known.map((name, i) => {
const isOn = selected.includes(name);
const itemProps = listbox.getItemProps(i);
return (
<button
key={name}
type="button"
{...itemProps}
ref={(el) => {
itemRefs.current[i] = el;
itemProps.ref(el);
}}
role="option"
aria-selected={isOn}
onClick={() => onToggle(name)}
onKeyDown={(e) => {
if (e.key === 'ArrowDown' && i === known.length - 1) {
e.preventDefault();
e.stopPropagation();
inputRef.current?.focus();
return;
}
itemProps.onKeyDown(e);
}}
className="text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none"
>
{isOn ? '✓ ' : ' '}{name}
</button>
);
})}
</div>
<div className="border-t mt-2 pt-2 flex gap-1.5">
<Input
ref={inputRef}
placeholder={t('tasks.newAssigneeName', 'New name…')}
value={newName}
onChange={(e) => onNewNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
onAddNew();
} else if (e.key === 'ArrowUp' && known.length > 0) {
e.preventDefault();
itemRefs.current[known.length - 1]?.focus();
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}}
className="h-8 text-sm flex-1"
/>
<Button type="button" size="sm" onClick={onAddNew} disabled={!newName.trim()}>
{t('common.add')}
</Button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function TaskFormDialog({
open,
onOpenChange,
mode,
taskId,
initialValues,
onSubmit,
isSubmitting,
}: Props) {
const { t } = useTranslation();
const [values, setValues] = useState<TaskFormValues>({ ...DEFAULTS, ...initialValues });
const [projectPopoverOpen, setProjectPopoverOpen] = useState(false);
const [priorityOpen, setPriorityOpen] = useState(false);
const [statusOpen, setStatusOpen] = useState(false);
const [assigneesOpen, setAssigneesOpen] = useState(false);
const [dueOpen, setDueOpen] = useState(false);
const [creatingProject, setCreatingProject] = useState(false);
const [assigneeInput, setAssigneeInput] = useState('');
const PILL_COUNT = 5; // Project, Priority, Status, Due, Assignees
const pillsRoving = useRovingFocus({ count: PILL_COUNT, direction: 'both' });
useEffect(() => {
if (open) {
setValues({ ...DEFAULTS, ...initialValues });
setCreatingProject(false);
setAssigneeInput('');
setProjectPopoverOpen(false);
setPriorityOpen(false);
setStatusOpen(false);
setAssigneesOpen(false);
setDueOpen(false);
}
}, [open, initialValues]);
function addNewAssignee() {
const name = assigneeInput.trim();
if (!name) return;
setValues((v) => (v.assignees.includes(name) ? v : { ...v, assignees: [...v.assignees, name] }));
setAssigneeInput('');
}
const handleDueChange = useCallback((d: Date | undefined) => {
setValues((v) => ({ ...v, dueDate: d ? d.getTime() : null }));
}, []);
const handleDueCommit = useCallback(() => {
setDueOpen(false);
}, []);
const { data: projectsList = [] } = trpc.projects.listAll.useQuery();
const { data: knownAssignees = [] } = trpc.tasks.listAssignees.useQuery();
const prefs = useFormatPrefs();
const selectedProject = projectsList.find((p) => p.id === values.projectId);
const attachments = useTaskAttachments(mode === 'edit' && taskId ? taskId : null);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!values.title.trim()) return;
onSubmit({ ...values, title: values.title.trim() });
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[580px] p-0 gap-0 overflow-hidden bg-card/92 backdrop-blur-xl">
<DialogHeader className="px-5 pt-5 pb-2">
<DialogTitle>
{mode === 'create' ? t('tasks.newTask') : t('tasks.editTask')}
</DialogTitle>
<DialogDescription>
{mode === 'create' ? t('tasks.newTaskDescription') : t('tasks.editTaskDescription')}
</DialogDescription>
</DialogHeader>
<form
onSubmit={handleSubmit}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') handleSubmit(e);
}}
>
<div className="px-5 pt-5 pb-2">
<input
autoFocus
className="w-full bg-transparent border-none outline-none text-[22px] font-medium leading-tight placeholder:text-muted-foreground/60"
placeholder={t('tasks.whatNeedsToBeDone')}
value={values.title}
onChange={(e) => setValues((v) => ({ ...v, title: e.target.value }))}
/>
<textarea
className="mt-2 w-full bg-transparent border-none outline-none text-sm resize-none placeholder:text-muted-foreground/60"
rows={3}
placeholder={t('tasks.descriptionOptional')}
value={values.description}
onChange={(e) => setValues((v) => ({ ...v, description: e.target.value }))}
/>
</div>
<div className="px-5 pb-3 pt-1">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2">
{t('tasks.properties')}
</div>
<div
className="flex flex-wrap gap-1.5"
data-testid="property-pills"
role="toolbar"
aria-label={t('tasks.properties')}
>
{/* Project */}
<Popover
open={projectPopoverOpen}
onOpenChange={(o) => {
setProjectPopoverOpen(o);
if (!o) setCreatingProject(false);
}}
>
<PopoverTrigger asChild>
<PropertyPill
{...pillsRoving.getItemProps(0)}
icon={<Folder className="h-3 w-3" />}
label={t('tasks.project')}
value={selectedProject?.name ?? null}
empty={!selectedProject}
aria-label={t('tasks.project') + (selectedProject ? `: ${selectedProject.name}` : '')}
/>
</PopoverTrigger>
<PopoverContent className="w-72 p-0 max-h-96 overflow-y-auto" align="start">
{projectPopoverOpen && (creatingProject ? (
<InlineProjectForm
onCancel={() => setCreatingProject(false)}
onCreated={(id) => {
setValues((v) => ({ ...v, projectId: id }));
setCreatingProject(false);
setProjectPopoverOpen(false);
}}
/>
) : (
<ProjectList
projects={projectsList}
selectedId={values.projectId}
onSelect={(id) => {
setValues((v) => ({ ...v, projectId: id }));
setProjectPopoverOpen(false);
}}
onCreate={() => setCreatingProject(true)}
onClose={() => setProjectPopoverOpen(false)}
/>
))}
</PopoverContent>
</Popover>
{/* Priority */}
<Popover open={priorityOpen} onOpenChange={setPriorityOpen}>
<PopoverTrigger asChild>
<PropertyPill
{...pillsRoving.getItemProps(1)}
icon={
values.priority === 'high' ? <ArrowUp className="h-3 w-3 text-red-600" /> :
values.priority === 'low' ? <ArrowDown className="h-3 w-3 text-muted-foreground" /> :
<ArrowRight className="h-3 w-3 text-amber-600" />
}
label={t('tasks.priority')}
value={t(`tasks.${values.priority}`)}
aria-label={t('tasks.priority') + `: ${t(`tasks.${values.priority}`)}`}
/>
</PopoverTrigger>
<PopoverContent className="w-40 p-0" align="start">
{priorityOpen && (
<PriorityList
value={values.priority}
onSelect={(p) => {
setValues((v) => ({ ...v, priority: p }));
setPriorityOpen(false);
}}
onClose={() => setPriorityOpen(false)}
/>
)}
</PopoverContent>
</Popover>
{/* Status */}
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
<PopoverTrigger asChild>
<PropertyPill
{...pillsRoving.getItemProps(2)}
icon={
values.status === 'in_progress' ? <Clock className="h-3 w-3" /> :
values.status === 'done' ? <CheckCircle2 className="h-3 w-3" /> :
<Circle className="h-3 w-3" />
}
label={t('tasks.status')}
value={t(values.status === 'todo' ? 'tasks.toDo' : values.status === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}
aria-label={t('tasks.status') + `: ${t(values.status === 'todo' ? 'tasks.toDo' : values.status === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}`}
/>
</PopoverTrigger>
<PopoverContent className="w-40 p-0" align="start">
{statusOpen && (
<StatusList
value={values.status}
onSelect={(s) => {
setValues((v) => ({ ...v, status: s }));
setStatusOpen(false);
}}
onClose={() => setStatusOpen(false)}
/>
)}
</PopoverContent>
</Popover>
{/* Due date */}
<Popover open={dueOpen} onOpenChange={setDueOpen}>
<PopoverTrigger asChild>
<PropertyPill
{...pillsRoving.getItemProps(3)}
icon={<CalIcon className="h-3 w-3" />}
label={t('tasks.colDue')}
value={values.dueDate ? formatDueDate(values.dueDate, prefs) : null}
empty={!values.dueDate}
aria-label={t('tasks.colDue')}
/>
</PopoverTrigger>
<PopoverContent className="w-auto p-3" align="start">
<DateTimeField
withTime
value={values.dueDate ? new Date(values.dueDate) : undefined}
onChange={handleDueChange}
onCommit={handleDueCommit}
aria-label={t('tasks.colDue')}
/>
</PopoverContent>
</Popover>
{/* Assignees */}
<Popover open={assigneesOpen} onOpenChange={setAssigneesOpen}>
<PopoverTrigger asChild>
<PropertyPill
{...pillsRoving.getItemProps(4)}
icon={<UserPlus className="h-3 w-3" />}
label={t('tasks.assignees')}
value={values.assignees.length > 0 ? values.assignees.join(', ') : null}
empty={values.assignees.length === 0}
aria-label={t('tasks.assignees') + (values.assignees.length > 0 ? `: ${values.assignees.join(', ')}` : '')}
/>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start">
{assigneesOpen && (
<AssigneesList
known={knownAssignees}
selected={values.assignees}
onToggle={(name) =>
setValues((v) => ({
...v,
assignees: v.assignees.includes(name)
? v.assignees.filter((a) => a !== name)
: [...v.assignees, name],
}))
}
onClose={() => setAssigneesOpen(false)}
newName={assigneeInput}
onNewNameChange={setAssigneeInput}
onAddNew={addNewAssignee}
/>
)}
</PopoverContent>
</Popover>
</div>
</div>
<div className="flex items-center justify-between gap-2 px-5 py-3 border-t border-border/40 bg-background/30">
<div>
{mode === 'edit' && taskId && (
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => attachments.addFiles()}
title={t('tasks.addFile')}
>
<Paperclip className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>
{t('common.cancel')}
</Button>
<Button type="submit" size="sm" disabled={!values.title.trim() || isSubmitting}>
{isSubmitting
? t('common.saving')
: mode === 'create'
? t('tasks.createTask')
: t('common.save')}
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,194 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Search, List, LayoutGrid, ClipboardCheck } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { Button } from '@/components/ui/button';
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
import { TaskTable } from './TaskTable';
import { TaskCard } from './TaskCard';
import { TaskPager } from './TaskPager';
import { TaskDetailSheet } from './TaskDetailSheet';
import { NewTaskDialog } from './NewTaskDialog';
import { EditTaskDialog } from './EditTaskDialog';
import { type TaskItem } from './task-types';
type StatusFilter = 'active' | 'todo' | 'in_progress' | 'all' | 'done';
type OrderBy = 'dueDate' | 'priority' | 'createdAt';
const PAGE_SIZE_KEY = 'tasksPageSize';
const VIEW_MODE_KEY = 'tasksViewMode';
function readPageSize(): number {
const v = Number(localStorage.getItem(PAGE_SIZE_KEY));
return [10, 25, 50, 100].includes(v) ? v : 25;
}
function readViewMode(): 'list' | 'grid' {
return (localStorage.getItem(VIEW_MODE_KEY) as 'list' | 'grid') ?? 'list';
}
export function TaskListView({
projectId,
hideProjectColumn,
}: {
projectId?: string;
hideProjectColumn?: boolean;
}) {
const { t } = useTranslation();
const utils = trpc.useUtils();
const { notify, notifyError } = useNotify();
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('active');
const [orderBy, setOrderBy] = useState<OrderBy>('dueDate');
const [viewMode, setViewMode] = useState<'list' | 'grid'>(readViewMode);
const [pageSize, setPageSize] = useState<number>(readPageSize);
const [pageIndex, setPageIndex] = useState(0);
const [newOpen, setNewOpen] = useState(false);
const [editTask, setEditTask] = useState<TaskItem | null>(null);
const [viewTask, setViewTask] = useState<TaskItem | null>(null);
useEffect(() => { localStorage.setItem(VIEW_MODE_KEY, viewMode); }, [viewMode]);
useEffect(() => { localStorage.setItem(PAGE_SIZE_KEY, String(pageSize)); }, [pageSize]);
// Reset page on any filter change
useEffect(() => { setPageIndex(0); }, [debouncedSearch, statusFilter, orderBy]);
// Search debounce
useEffect(() => {
const id = setTimeout(() => setDebouncedSearch(search), 300);
return () => clearTimeout(id);
}, [search]);
const backendStatus = statusFilter === 'todo' || statusFilter === 'in_progress' || statusFilter === 'done'
? statusFilter : undefined;
const queryInput = useMemo(() => ({
...(backendStatus ? { status: backendStatus } : {}),
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
...(projectId ? { projectId } : {}),
orderBy,
}), [backendStatus, debouncedSearch, orderBy, projectId]);
const { data: filteredTasks } = trpc.tasks.list.useQuery(queryInput);
const updateTask = trpc.tasks.update.useMutation({
onSuccess: () => 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.list.invalidate();
},
onError: (err) => notifyError('toast.task.deleteError', err),
});
const tasksAll = (filteredTasks ?? [])
.filter((task) => statusFilter !== 'active' || task.status === 'todo' || task.status === 'in_progress');
const total = tasksAll.length;
const lastPage = Math.max(0, Math.ceil(total / pageSize) - 1);
const safePageIndex = Math.min(pageIndex, lastPage);
if (safePageIndex !== pageIndex) setPageIndex(safePageIndex);
const pageTasks = tasksAll.slice(safePageIndex * pageSize, (safePageIndex + 1) * pageSize);
return (
<div className="flex flex-col gap-4">
{/* Toolbar */}
<div className="flex flex-wrap items-center justify-between gap-3">
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
<TabsList>
<TabsTrigger value="active">{t('tasks.active')}</TabsTrigger>
<TabsTrigger value="todo">{t('tasks.toDo')}</TabsTrigger>
<TabsTrigger value="in_progress">{t('tasks.inProgress')}</TabsTrigger>
<TabsTrigger value="done">{t('tasks.done')}</TabsTrigger>
<TabsTrigger value="all">{t('tasks.all')}</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex flex-wrap items-center gap-3">
<InputGroup className="w-56">
<InputGroupAddon><Search /></InputGroupAddon>
<InputGroupInput placeholder={t('tasks.searchPlaceholder')} value={search} onChange={(e) => setSearch(e.target.value)} />
</InputGroup>
<Select value={orderBy} onValueChange={(v) => setOrderBy(v as OrderBy)}>
<SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="dueDate">{t('tasks.orderByDue')}</SelectItem>
<SelectItem value="priority">{t('tasks.orderByPriority')}</SelectItem>
<SelectItem value="createdAt">{t('tasks.orderByCreated')}</SelectItem>
</SelectContent>
</Select>
<ToggleGroup type="single" value={viewMode} onValueChange={(v) => v && setViewMode(v as 'list' | 'grid')} variant="outline" size="sm">
<ToggleGroupItem value="list" aria-label={t('tasks.viewList')}><List /></ToggleGroupItem>
<ToggleGroupItem value="grid" aria-label={t('tasks.viewGrid')}><LayoutGrid /></ToggleGroupItem>
</ToggleGroup>
<Button size="sm" onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4 mr-1" />{t('tasks.newTask')}
</Button>
</div>
</div>
{/* Body */}
{total === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon"><ClipboardCheck /></EmptyMedia>
<EmptyTitle>{t('tasks.noTasksFound')}</EmptyTitle>
<EmptyDescription>{t('tasks.noTasksDescription')}</EmptyDescription>
</EmptyHeader>
</Empty>
) : viewMode === 'grid' ? (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{pageTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
onStatusChange={(id, status) => updateTask.mutate({ id, status })}
onEdit={setEditTask}
onDelete={(id) => deleteTask.mutate({ id })}
onClick={setViewTask}
/>
))}
</div>
) : (
<TaskTable
tasks={pageTasks}
hideProjectColumn={hideProjectColumn}
onRowClick={setViewTask}
onEdit={setEditTask}
onDelete={(id) => deleteTask.mutate({ id })}
onStatusChange={(id, status) => updateTask.mutate({ id, status })}
/>
)}
{/* Pager (always visible when there are tasks) */}
{total > 0 && (
<TaskPager
total={total}
pageIndex={safePageIndex}
pageSize={pageSize}
onPageChange={setPageIndex}
onPageSizeChange={(s) => { setPageSize(s); setPageIndex(0); }}
/>
)}
<NewTaskDialog open={newOpen} onOpenChange={setNewOpen} defaultProjectId={projectId} />
<EditTaskDialog task={editTask} open={!!editTask} onOpenChange={(o) => { if (!o) setEditTask(null); }} />
<TaskDetailSheet
task={viewTask}
open={!!viewTask}
onOpenChange={(o) => { if (!o) setViewTask(null); }}
onEdit={(task) => { setViewTask(null); setEditTask(task); }}
onDelete={(id) => { deleteTask.mutate({ id }); setViewTask(null); }}
/>
</div>
);
}

View File

@@ -0,0 +1,100 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';
interface Props {
total: number;
pageIndex: number;
pageSize: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
}
const PAGE_SIZES = [10, 25, 50, 100];
function buildWindow(current: number, last: number, max: number): Array<number | 'ellipsis'> {
if (last <= 0) return [0];
if (last < max) return Array.from({ length: last + 1 }, (_, i) => i);
const window: Array<number | 'ellipsis'> = [];
const halfMax = Math.floor((max - 2) / 2);
let start = Math.max(1, current - halfMax);
let end = Math.min(last - 1, current + halfMax);
if (current - halfMax < 1) end = Math.min(last - 1, max - 2);
if (current + halfMax > last - 1) start = Math.max(1, last - (max - 2));
window.push(0);
if (start > 1) window.push('ellipsis');
for (let i = start; i <= end; i++) window.push(i);
if (end < last - 1) window.push('ellipsis');
window.push(last);
return window;
}
export function TaskPager({ total, pageIndex, pageSize, onPageChange, onPageSizeChange }: Props) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const [maxButtons, setMaxButtons] = useState(7);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
const w = entry.contentRect.width;
setMaxButtons(w < 480 ? 3 : w < 640 ? 5 : 7);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const lastPage = Math.max(0, Math.ceil(total / pageSize) - 1);
const start = total === 0 ? 0 : pageIndex * pageSize + 1;
const end = Math.min(total, (pageIndex + 1) * pageSize);
const window = buildWindow(pageIndex, lastPage, maxButtons);
return (
<div
ref={containerRef}
className="rounded-lg border border-border/50 bg-card/65 backdrop-blur-xl shadow-sm flex items-center justify-between px-4 py-2 gap-3 flex-wrap"
>
<span className="text-xs text-muted-foreground">
<Trans
i18nKey="tasks.showingNofM"
values={{ start, end, total }}
components={{ b: <span className="font-medium text-foreground" /> }}
/>
</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{t('tasks.rowsPerPage')}</span>
<Select value={String(pageSize)} onValueChange={(v) => onPageSizeChange(Number(v))}>
<SelectTrigger className="h-7 w-[68px]"><SelectValue /></SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((s) => <SelectItem key={s} value={String(s)}>{s}</SelectItem>)}
</SelectContent>
</Select>
<Button variant="ghost" size="icon" className="h-7 w-7" disabled={pageIndex === 0} onClick={() => onPageChange(pageIndex - 1)}>
<ChevronLeft className="h-4 w-4" />
</Button>
{window.map((p, i) =>
p === 'ellipsis' ? (
<span key={`e${i}`} className="px-1 text-muted-foreground"></span>
) : (
<Button
key={p}
variant={p === pageIndex ? 'default' : 'ghost'}
size="sm"
className={cn('h-7 min-w-7 px-2 text-xs')}
onClick={() => onPageChange(p)}
>
{p + 1}
</Button>
),
)}
<Button variant="ghost" size="icon" className="h-7 w-7" disabled={pageIndex >= lastPage} onClick={() => onPageChange(pageIndex + 1)}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@@ -20,23 +20,7 @@ import {
import { PriorityBadge } from './PriorityBadge';
import { useFormatPrefs, formatDueDate } from '@/lib/date';
import { parseAssignees } from './task-utils';
export type TaskItem = {
id: string;
projectId: string | null;
title: string;
description: string | null;
status: string | null;
priority: string | null;
assignee: string | null;
dueDate: number | null;
isAiSuggested: number;
projectName: string | null;
clientName: string | null;
subClientName: string | null;
};
export { parseAssignees } from './task-utils';
import type { TaskItem } from './task-types';
export function TaskRow({
task,

View File

@@ -0,0 +1,62 @@
import { useTranslation } from 'react-i18next';
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
} from '@/components/ui/table';
import { useFormatPrefs } from '@/lib/date';
import { type TaskItem } from './task-types';
import { TaskTableRow } from './TaskTableRow';
interface Props {
tasks: TaskItem[];
hideProjectColumn?: boolean;
onRowClick: (task: TaskItem) => void;
onEdit: (task: TaskItem) => void;
onDelete: (id: string) => void;
onStatusChange: (id: string, status: string) => void;
}
export function TaskTable({
tasks,
hideProjectColumn,
onRowClick,
onEdit,
onDelete,
onStatusChange,
}: Props) {
const { t } = useTranslation();
const prefs = useFormatPrefs();
return (
<div className="rounded-lg border border-border/50 bg-card/65 backdrop-blur-xl shadow-sm overflow-hidden">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>{t('tasks.colTask')}</TableHead>
{!hideProjectColumn && <TableHead>{t('tasks.colProject')}</TableHead>}
<TableHead>{t('tasks.colPriority')}</TableHead>
<TableHead>{t('tasks.colDue')}</TableHead>
<TableHead>{t('tasks.colAssignee')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tasks.map((task) => (
<TaskTableRow
key={task.id}
task={task}
hideProjectColumn={hideProjectColumn}
onClick={() => onRowClick(task)}
onEdit={() => onEdit(task)}
onDelete={() => onDelete(task.id)}
onStatusChange={(s) => onStatusChange(task.id, s)}
prefs={prefs}
/>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import { useTranslation } from 'react-i18next';
import { ChevronRight, Pencil, Trash2, Check, RefreshCw } from 'lucide-react';
import {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuSub,
ContextMenuSubTrigger,
ContextMenuSubContent,
} from '@/components/ui/context-menu';
import { TableRow, TableCell } from '@/components/ui/table';
import { cn } from '@/lib/utils';
import { formatDueDate, isOverdue, type FormatPrefs } from '@/lib/date';
import { parseAssignees } from './task-utils';
import type { TaskItem } from './task-types';
import { PriorityBadge } from './PriorityBadge';
import { AssigneeStack } from './AssigneeStack';
const STATUSES = ['todo', 'in_progress', 'done'] as const;
export function TaskTableRow({
task,
hideProjectColumn,
onClick,
onEdit,
onDelete,
onStatusChange,
prefs,
}: {
task: TaskItem;
hideProjectColumn?: boolean;
onClick: () => void;
onEdit: () => void;
onDelete: () => void;
onStatusChange: (status: string) => void;
prefs: FormatPrefs;
}) {
const { t } = useTranslation();
const assignees = parseAssignees(task.assignee);
const overdue = task.dueDate ? isOverdue(task.dueDate) && task.status !== 'done' : false;
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<TableRow className="cursor-pointer" onClick={onClick}>
<TableCell className="font-medium max-w-[280px] truncate">{task.title}</TableCell>
{!hideProjectColumn && (
<TableCell className="text-xs">
{task.clientName && <span className="text-muted-foreground">{task.clientName}</span>}
{task.clientName && task.projectName && (
<ChevronRight className="inline h-3 w-3 mx-1 text-muted-foreground" />
)}
{task.projectName && <span>{task.projectName}</span>}
{!task.projectName && <span className="text-muted-foreground"></span>}
</TableCell>
)}
<TableCell><PriorityBadge priority={task.priority} /></TableCell>
<TableCell className={cn(overdue && 'text-red-600 dark:text-red-400')}>
{task.dueDate ? formatDueDate(task.dueDate, prefs) : <span className="text-muted-foreground"></span>}
</TableCell>
<TableCell><AssigneeStack assignees={assignees} /></TableCell>
</TableRow>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={onEdit}>
<Pencil className="h-4 w-4 mr-2" />
{t('common.edit')}
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>
<RefreshCw className="h-4 w-4 mr-2" />
{t('tasks.changeStatus')}
</ContextMenuSubTrigger>
<ContextMenuSubContent>
{STATUSES.map((s) => (
<ContextMenuItem key={s} onSelect={() => onStatusChange(s)}>
{task.status === s ? <Check className="h-3 w-3 mr-2" /> : <span className="w-5" />}
{t(s === 'todo' ? 'tasks.toDo' : s === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem onSelect={onDelete} className="text-destructive focus:text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
{t('common.delete')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}

View File

@@ -0,0 +1,16 @@
export type TaskItem = {
id: string;
projectId: string | null;
title: string;
description: string | null;
status: string | null;
priority: string | null;
assignee: string | null;
dueDate: number | null;
estimate: number | null;
isAiSuggested: number;
projectName: string | null;
clientName: string | null;
subClientName: string | null;
createdAt: number | null;
};

View File

@@ -0,0 +1,42 @@
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
const MAX_SIZE = 50 * 1024 * 1024;
export function useTaskAttachments(taskId: string | null) {
const utils = trpc.useUtils();
const { notify, notifyError } = useNotify();
const list = trpc.taskAttachments.list.useQuery(
{ taskId: taskId ?? '' },
{ enabled: !!taskId },
);
const pick = trpc.taskAttachments.pick.useMutation();
const create = trpc.taskAttachments.create.useMutation({
onSuccess: () => taskId && void utils.taskAttachments.list.invalidate({ taskId }),
onError: (err) => notifyError('toast.attachment.createError', err),
});
const remove = trpc.taskAttachments.delete.useMutation({
onSuccess: () => taskId && void utils.taskAttachments.list.invalidate({ taskId }),
});
const open = trpc.taskAttachments.open.useMutation();
async function addFiles() {
if (!taskId) return;
const picked = await pick.mutateAsync();
for (const f of picked) {
if (f.size > MAX_SIZE) {
notify('warning', 'toast.attachment.tooLarge', { values: { filename: f.name } });
continue;
}
await create.mutateAsync({
taskId,
sourcePath: f.path,
filename: f.name,
sizeBytes: f.size,
});
}
}
return { list, addFiles, remove, open };
}

View File

@@ -1,14 +1,14 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useFormatPrefs, formatDate } from '@/lib/date';
import { CalendarIcon, Check } from 'lucide-react';
import { type DateRange } from 'react-day-picker';
import { Check, X } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
@@ -21,10 +21,10 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import { ScrollArea } from '@/components/ui/scroll-area';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { DateField } from '@/components/ui/date-field';
import { cn } from '@/lib/utils';
import type { TimelineEventType } from './ProjectTimeline';
import type { HistoryEntry } from './history-types';
@@ -35,218 +35,414 @@ interface AddEventDialogProps {
onRecordHistory?: (entry: HistoryEntry) => void;
}
interface AddedEntry {
type StagedEvent = {
id: string;
title: string;
type: TimelineEventType;
date: Date;
endDate?: Date;
type: TimelineEventType;
};
type Mode = { kind: 'add' } | { kind: 'edit'; id: string };
function newLocalId(): string {
return 'staged_' + Math.random().toString(36).slice(2, 10);
}
export function AddEventDialog({ open, onOpenChange, defaultProjectId, onRecordHistory }: AddEventDialogProps) {
const { t } = useTranslation();
const prefs = useFormatPrefs();
const { notify, notifyError } = useNotify();
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
const [staged, setStaged] = useState<StagedEvent[]>([]);
const [mode, setMode] = useState<Mode>({ kind: 'add' });
const [title, setTitle] = useState('');
const [type, setType] = useState<TimelineEventType>('milestone');
const [dateRange, setDateRange] = useState<DateRange | undefined>();
const [singleDate, setSingleDate] = useState<Date | undefined>();
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
const [added, setAdded] = useState<AddedEntry[]>([]);
const [date, setDate] = useState<Date | undefined>();
const [endDate, setEndDate] = useState<Date | undefined>();
const titleRef = useRef<HTMLInputElement>(null);
const closedRef = useRef(false);
const [focusedRowId, setFocusedRowId] = useState<string | null>(null);
const rowRefs = useRef<Map<string, HTMLLIElement>>(new Map());
const stagedListRef = useRef<HTMLUListElement>(null);
const showProjectSelect = !defaultProjectId;
const projectLocked = staged.length > 0;
const isActivity = type === 'activity';
const { data: projectsList } = trpc.projects.listAll.useQuery(undefined, {
enabled: showProjectSelect,
});
const utils = trpc.useUtils();
const { notify, notifyError } = useNotify();
const createEvent = trpc.timelineEvents.create.useMutation();
const createEvent = trpc.timelineEvents.create.useMutation({
onSuccess: (data, variables) => {
notify('success', 'toast.timeline.created');
void utils.timelineEvents.list.invalidate();
onRecordHistory?.({
kind: 'create',
id: data.id,
payload: {
id: data.id,
projectId: variables.projectId ?? null,
title: variables.title,
date: variables.date,
endDate: variables.endDate ?? null,
type: (variables.type ?? 'milestone') as 'milestone' | 'checkpoint' | 'activity',
isCompleted: 0,
isAiSuggested: 0,
},
});
setAdded((prev) => [
...prev,
{
title: variables.title,
date: new Date(variables.date),
endDate: variables.endDate ? new Date(variables.endDate) : undefined,
type: variables.type as TimelineEventType,
},
]);
setTitle('');
setDateRange(undefined);
setSingleDate(undefined);
},
onError: (err) => notifyError('toast.timeline.createError', err),
});
function resetForm() {
setTitle('');
setDate(undefined);
setEndDate(undefined);
setMode({ kind: 'add' });
setTimeout(() => titleRef.current?.focus(), 0);
}
function handleClose() {
closedRef.current = true;
setTitle('');
setType('milestone');
setDateRange(undefined);
setSingleDate(undefined);
setDate(undefined);
setEndDate(undefined);
setProjectId(defaultProjectId ?? '');
setAdded([]);
setStaged([]);
setMode({ kind: 'add' });
onOpenChange(false);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const pid = defaultProjectId || projectId || undefined;
function attemptClose() {
if (staged.length === 0) {
handleClose();
return;
}
const ok = window.confirm(t('timeline.confirmCloseStaged', { count: staged.length }));
if (ok) handleClose();
}
if (isActivity) {
if (!title.trim() || !dateRange?.from) return;
const hasDuration = dateRange.to && dateRange.to.getTime() !== dateRange.from.getTime();
createEvent.mutate({
title: title.trim(),
date: dateRange.from.getTime(),
endDate: hasDuration ? dateRange.to!.getTime() : undefined,
type: 'activity',
projectId: pid,
});
function formValid(): boolean {
if (!title.trim()) return false;
if (!date) return false;
if (isActivity && endDate && endDate < date) return false;
if (showProjectSelect && !projectId) return false;
return true;
}
function stageOrUpdate() {
if (!formValid() || !date) return;
const entry: StagedEvent = {
id: mode.kind === 'edit' ? mode.id : newLocalId(),
title: title.trim(),
type,
date,
endDate: isActivity ? endDate : undefined,
};
if (mode.kind === 'edit') {
setStaged((prev) => prev.map((e) => (e.id === entry.id ? entry : e)));
} else {
if (!title.trim() || !singleDate) return;
createEvent.mutate({
title: title.trim(),
date: singleDate.getTime(),
type,
projectId: pid,
});
setStaged((prev) => [...prev, entry]);
}
resetForm();
}
async function saveBatch() {
if (staged.length === 0) return;
const pid = defaultProjectId || projectId || undefined;
closedRef.current = false;
const results = await Promise.allSettled(
staged.map((e) =>
createEvent.mutateAsync({
title: e.title,
date: e.date.getTime(),
endDate: e.endDate ? e.endDate.getTime() : undefined,
type: e.type,
projectId: pid,
}),
),
);
let okCount = 0;
const failedIds = new Set<string>();
results.forEach((r, i) => {
const s = staged[i];
if (r.status === 'fulfilled') {
okCount += 1;
onRecordHistory?.({
kind: 'create',
id: r.value.id,
payload: {
id: r.value.id,
projectId: pid ?? null,
title: s.title,
date: s.date.getTime(),
endDate: s.endDate ? s.endDate.getTime() : null,
type: s.type,
isCompleted: 0,
isAiSuggested: 0,
},
});
} else {
failedIds.add(s.id);
}
});
if (closedRef.current) return;
void utils.timelineEvents.list.invalidate();
if (failedIds.size === 0) {
notify('success', 'toast.timeline.batchCreated', { count: okCount });
handleClose();
return;
}
if (okCount === 0) {
const firstError = results.find((r) => r.status === 'rejected') as PromiseRejectedResult | undefined;
notifyError('toast.timeline.batchFailed', firstError?.reason);
} else {
notify('warning', 'toast.timeline.batchPartial', { ok: okCount, failed: failedIds.size });
}
setStaged((prev) => prev.filter((e) => failedIds.has(e.id)));
}
function loadRowIntoForm(row: StagedEvent) {
setTitle(row.title);
setType(row.type);
setDate(row.date);
setEndDate(row.endDate);
setMode({ kind: 'edit', id: row.id });
setFocusedRowId(null);
setTimeout(() => titleRef.current?.focus(), 0);
}
function removeRow(id: string) {
const idx = staged.findIndex((s) => s.id === id);
setStaged((prev) => prev.filter((s) => s.id !== id));
setFocusedRowId(null);
if (mode.kind === 'edit' && mode.id === id) {
setMode({ kind: 'add' });
}
setTimeout(() => {
const next = staged[idx + 1] ?? staged[idx - 1];
if (next) {
const el = rowRefs.current.get(next.id);
if (el) {
setFocusedRowId(next.id);
el.focus();
return;
}
}
titleRef.current?.focus();
}, 0);
}
function onRowKeyDown(e: React.KeyboardEvent<HTMLLIElement>, row: StagedEvent) {
const idx = staged.findIndex((s) => s.id === row.id);
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = staged[idx + 1];
if (next) {
setFocusedRowId(next.id);
rowRefs.current.get(next.id)?.focus();
} else {
setFocusedRowId(null);
titleRef.current?.focus();
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prev = staged[idx - 1];
if (prev) {
setFocusedRowId(prev.id);
rowRefs.current.get(prev.id)?.focus();
}
} else if (e.key === 'Enter') {
e.preventDefault();
loadRowIntoForm(row);
} else if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
removeRow(row.id);
} else if (e.key === 'Escape') {
e.preventDefault();
setFocusedRowId(null);
titleRef.current?.focus();
}
}
const canSubmit = isActivity ? (title.trim() && dateRange?.from) : (title.trim() && singleDate);
function onFormKeyDown(e: React.KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
void saveBatch();
} else if (e.key === 'Enter') {
e.preventDefault();
stageOrUpdate();
} else if (e.key === 'Escape') {
e.preventDefault();
attemptClose();
}
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); else onOpenChange(v); }}>
<DialogContent className="sm:max-w-[420px]">
<Dialog open={open} onOpenChange={(v) => { if (!v) attemptClose(); else onOpenChange(v); }}>
<DialogContent className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle>{t('timeline.addEventTitle')}</DialogTitle>
<DialogDescription>{t('timeline.addEventDescription')}</DialogDescription>
</DialogHeader>
{added.length > 0 && (
<ScrollArea className="max-h-32">
<div className="flex flex-col gap-1.5">
{added.map((entry, i) => (
<div key={i} className="flex items-center gap-2 text-sm text-muted-foreground">
<Check className="h-3.5 w-3.5 text-chart-2 shrink-0" />
<span className="truncate">{entry.title}</span>
<span className="ml-auto text-xs shrink-0">
{entry.type === 'milestone' ? t('timeline.typeMilestone') : entry.type === 'checkpoint' ? t('timeline.typeCheckpoint') : t('timeline.typeActivity')}
</span>
</div>
{showProjectSelect && (
<Select
value={projectId}
onValueChange={setProjectId}
disabled={projectLocked}
>
<SelectTrigger title={projectLocked ? t('timeline.projectLocked') : undefined}>
<SelectValue placeholder={t('timeline.selectProjectOptional')} />
</SelectTrigger>
<SelectContent>
{projectsList?.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</div>
</SelectContent>
</Select>
)}
{staged.length === 0 ? (
<p className="text-xs text-muted-foreground py-2">{t('timeline.emptyStagedHint')}</p>
) : (
<ScrollArea className="max-h-40 border rounded-md">
<ul ref={stagedListRef} className="flex flex-col" role="listbox" aria-label={t('timeline.staged', { count: staged.length })}>
{staged.map((e) => (
<li
key={e.id}
ref={(el) => {
if (el) rowRefs.current.set(e.id, el);
else rowRefs.current.delete(e.id);
}}
tabIndex={focusedRowId === e.id ? 0 : -1}
role="option"
aria-selected={focusedRowId === e.id}
onKeyDown={(ev) => onRowKeyDown(ev, e)}
onFocus={() => setFocusedRowId(e.id)}
onBlur={(ev) => {
const next = ev.relatedTarget as Node | null;
if (!next || !stagedListRef.current?.contains(next)) {
setFocusedRowId(null);
}
}}
className={cn(
'flex items-center gap-2 px-2 py-1.5 text-sm outline-none',
focusedRowId === e.id && 'bg-accent/40',
mode.kind === 'edit' && mode.id === e.id && 'ring-1 ring-primary/40',
)}
>
<Check className="h-3.5 w-3.5 text-chart-2 shrink-0" />
<span className="truncate flex-1">{e.title}</span>
<span className="text-xs text-muted-foreground shrink-0">
{e.type === 'milestone'
? t('timeline.typeMilestone')
: e.type === 'checkpoint'
? t('timeline.typeCheckpoint')
: t('timeline.typeActivity')}
</span>
<span className="text-xs text-muted-foreground shrink-0">
{e.endDate
? `${formatDate(e.date.getTime(), prefs)} ${formatDate(e.endDate.getTime(), prefs)}`
: formatDate(e.date.getTime(), prefs)}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
aria-label={t('timeline.removeRow')}
tabIndex={-1}
onClick={() => removeRow(e.id)}
>
<X className="h-3.5 w-3.5" />
</Button>
</li>
))}
</ul>
</ScrollArea>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{/* Event type selector */}
<ToggleGroup
type="single"
value={type}
onValueChange={(v) => { if (v) setType(v as TimelineEventType); }}
className="justify-start"
>
<ToggleGroupItem value="milestone" className="text-xs px-3">{t('timeline.typeMilestone')}</ToggleGroupItem>
<ToggleGroupItem value="checkpoint" className="text-xs px-3">{t('timeline.typeCheckpoint')}</ToggleGroupItem>
<ToggleGroupItem value="activity" className="text-xs px-3">{t('timeline.typeActivity')}</ToggleGroupItem>
</ToggleGroup>
<div
className={cn(
'flex flex-col gap-3 transition-opacity',
focusedRowId !== null && 'opacity-50 pointer-events-none',
)}
onKeyDown={onFormKeyDown}
>
<Tabs value={type} onValueChange={(v) => setType(v as TimelineEventType)}>
<TabsList>
<TabsTrigger value="milestone">{t('timeline.typeMilestone')}</TabsTrigger>
<TabsTrigger value="checkpoint">{t('timeline.typeCheckpoint')}</TabsTrigger>
<TabsTrigger value="activity">{t('timeline.typeActivity')}</TabsTrigger>
</TabsList>
</Tabs>
<Input
ref={titleRef}
placeholder={t('timeline.eventTitlePlaceholder')}
aria-label={t('timeline.eventTitlePlaceholder')}
value={title}
onChange={(e) => setTitle(e.target.value)}
required
onKeyDown={(e) => {
if (
e.key === 'ArrowUp' &&
staged.length > 0 &&
(e.currentTarget.selectionStart ?? 0) === 0
) {
e.preventDefault();
const last = staged[staged.length - 1];
setFocusedRowId(last.id);
rowRefs.current.get(last.id)?.focus();
}
}}
autoFocus
/>
{/* Date picker: range for activities, single for milestone/checkpoint */}
{isActivity ? (
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="outline" className="justify-start px-2.5 font-normal">
<CalendarIcon className="mr-2 h-4 w-4" />
{dateRange?.from ? (
dateRange.to && dateRange.to.getTime() !== dateRange.from.getTime() ? (
<>{formatDate(dateRange.from.getTime(), prefs)} {formatDate(dateRange.to.getTime(), prefs)}</>
) : (
formatDate(dateRange.from.getTime(), prefs)
)
) : (
<span className="text-muted-foreground">{t('timeline.pickDateRange')}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={setDateRange}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
<div className="flex gap-2">
<DateField
value={date}
onChange={(d) => {
setDate(d);
if (d && endDate && endDate < d) setEndDate(undefined);
}}
placeholder={t('timeline.pickStart')}
aria-label={t('timeline.pickStart')}
className="flex-1"
/>
<DateField
value={endDate}
onChange={setEndDate}
minDate={date}
placeholder={t('timeline.pickEnd')}
aria-label={t('timeline.pickEnd')}
invalidMessage={
date && endDate && endDate < date ? t('timeline.endBeforeStart') : undefined
}
className="flex-1"
/>
</div>
) : (
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="outline" className="justify-start px-2.5 font-normal">
<CalendarIcon className="mr-2 h-4 w-4" />
{singleDate ? (
formatDate(singleDate.getTime(), prefs)
) : (
<span className="text-muted-foreground">{t('timeline.pickDate')}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={singleDate}
onSelect={setSingleDate}
/>
</PopoverContent>
</Popover>
<DateField
value={date}
onChange={setDate}
placeholder={t('timeline.pickDate')}
aria-label={t('timeline.pickDate')}
/>
)}
</div>
{showProjectSelect && (
<Select value={projectId} onValueChange={setProjectId}>
<SelectTrigger>
<SelectValue placeholder={t('timeline.selectProjectOptional')} />
</SelectTrigger>
<SelectContent>
{projectsList?.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
{added.length > 0 ? t('common.done') : t('common.cancel')}
</Button>
<Button type="submit" disabled={!canSubmit || createEvent.isPending}>
{added.length > 0 ? t('timeline.addAnother') : t('common.add')}
</Button>
</DialogFooter>
</form>
<DialogFooter>
<Button type="button" variant="outline" onClick={attemptClose}>
{t('common.cancel')}
</Button>
<Button
type="button"
variant="outline"
onClick={stageOrUpdate}
disabled={!formValid()}
>
{mode.kind === 'edit' ? t('timeline.update') : t('common.add')}
</Button>
<Button
type="button"
onClick={() => void saveBatch()}
disabled={staged.length === 0 || createEvent.isPending}
>
{t('timeline.saveAll', { count: staged.length })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);

View File

@@ -1,8 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useFormatPrefs, formatDate } from '@/lib/date';
import { CalendarIcon } from 'lucide-react';
import { type DateRange } from 'react-day-picker';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { Button } from '@/components/ui/button';
@@ -14,8 +11,7 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import { DateField } from '@/components/ui/date-field';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import type { TimelineEvent, TimelineEventType } from './ProjectTimeline';
import type { HistoryEntry } from './history-types';
@@ -28,11 +24,10 @@ interface EditEventDialogProps {
export function EditEventDialog({ event, onOpenChange, onRecordHistory }: EditEventDialogProps) {
const { t } = useTranslation();
const prefs = useFormatPrefs();
const [title, setTitle] = useState('');
const [type, setType] = useState<TimelineEventType>('milestone');
const [dateRange, setDateRange] = useState<DateRange | undefined>();
const [singleDate, setSingleDate] = useState<Date | undefined>();
const [date, setDate] = useState<Date | undefined>();
const [endDate, setEndDate] = useState<Date | undefined>();
const pendingPrevRef = useRef<HistoryEntry | null>(null);
@@ -42,13 +37,11 @@ export function EditEventDialog({ event, onOpenChange, onRecordHistory }: EditEv
if (event) {
setTitle(event.title);
setType(event.type ?? 'milestone');
const from = new Date(event.date);
setDate(new Date(event.date));
if (event.type === 'activity' && event.endDate) {
setDateRange({ from, to: new Date(event.endDate) });
setSingleDate(undefined);
setEndDate(new Date(event.endDate));
} else {
setSingleDate(from);
setDateRange(undefined);
setEndDate(undefined);
}
}
}, [event]);
@@ -74,13 +67,11 @@ export function EditEventDialog({ event, onOpenChange, onRecordHistory }: EditEv
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!event || !title.trim()) return;
if (!event || !title.trim() || !date) return;
if (isActivity) {
if (!dateRange?.from) return;
const hasDuration = dateRange.to && dateRange.to.getTime() !== dateRange.from.getTime();
const nextDate = dateRange.from.getTime();
const nextEndDate = hasDuration ? dateRange.to!.getTime() : null;
const nextDate = date.getTime();
const nextEndDate = (endDate && endDate.getTime() !== date.getTime()) ? endDate.getTime() : null;
pendingPrevRef.current = {
kind: 'update',
id: event.id,
@@ -94,8 +85,7 @@ export function EditEventDialog({ event, onOpenChange, onRecordHistory }: EditEv
};
updateEvent.mutate({ id: event.id, title: title.trim(), type: 'activity', date: nextDate, endDate: nextEndDate });
} else {
if (!singleDate) return;
const nextDate = singleDate.getTime();
const nextDate = date.getTime();
pendingPrevRef.current = {
kind: 'update',
id: event.id,
@@ -111,7 +101,7 @@ export function EditEventDialog({ event, onOpenChange, onRecordHistory }: EditEv
}
}
const canSubmit = isActivity ? (title.trim() && dateRange?.from) : (title.trim() && singleDate);
const canSubmit = title.trim() && date;
return (
<Dialog open={!!event} onOpenChange={onOpenChange}>
@@ -140,51 +130,30 @@ export function EditEventDialog({ event, onOpenChange, onRecordHistory }: EditEv
/>
{isActivity ? (
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="outline" className="justify-start px-2.5 font-normal">
<CalendarIcon className="mr-2 h-4 w-4" />
{dateRange?.from ? (
dateRange.to && dateRange.to.getTime() !== dateRange.from.getTime() ? (
<>{formatDate(dateRange.from.getTime(), prefs)} {formatDate(dateRange.to.getTime(), prefs)}</>
) : (
formatDate(dateRange.from.getTime(), prefs)
)
) : (
<span className="text-muted-foreground">{t('timeline.pickDateRange')}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={setDateRange}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
<div className="flex gap-2">
<DateField
value={date}
onChange={setDate}
placeholder={t('timeline.pickStart')}
aria-label={t('timeline.pickStart')}
className="flex-1"
/>
<DateField
value={endDate}
onChange={setEndDate}
minDate={date}
placeholder={t('timeline.pickEnd')}
aria-label={t('timeline.pickEnd')}
className="flex-1"
/>
</div>
) : (
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="outline" className="justify-start px-2.5 font-normal">
<CalendarIcon className="mr-2 h-4 w-4" />
{singleDate ? (
formatDate(singleDate.getTime(), prefs)
) : (
<span className="text-muted-foreground">{t('timeline.pickDate')}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={singleDate}
onSelect={setSingleDate}
/>
</PopoverContent>
</Popover>
<DateField
value={date}
onChange={setDate}
placeholder={t('timeline.pickDate')}
aria-label={t('timeline.pickDate')}
/>
)}
<DialogFooter>

View File

@@ -28,7 +28,6 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { ProjectTimeline, GANTT_LABEL_WIDTH, type TimelineEvent } from './ProjectTimeline';
import { TimelineAxisHeader, HEADER_HEIGHT } from './TimelineAxisHeader';
import { type ProjectGroup } from './ProjectTimelineBox';
import { useFloatingChat } from '@/context/FloatingChatContext';
type ZoomLevel = 'day' | 'week' | 'month';
const COLUMN_PX = 32;
@@ -55,8 +54,6 @@ export interface TimelineGanttViewProps {
onEdit: (ev: TimelineEvent) => void;
onDuplicate: (ev: TimelineEvent) => void;
onMove: (id: string, date: number, endDate: number | null) => void;
sectionId: string;
sectionLabel: string;
projectId?: string;
className?: string;
}
@@ -75,8 +72,6 @@ function TimelineGanttViewInner({
onEdit,
onDuplicate,
onMove,
sectionId,
sectionLabel,
projectId,
className,
}: TimelineGanttViewProps) {
@@ -92,8 +87,6 @@ function TimelineGanttViewInner({
const { data: savedZoom } = trpc.settings.getTimelineZoom.useQuery();
const saveZoom = trpc.settings.setTimelineZoom.useMutation();
const { registerSection, unregisterSection } = useFloatingChat();
useEffect(() => {
if (savedZoom && savedZoom !== zoomLevel) setZoomLevel(savedZoom as ZoomLevel);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -104,11 +97,6 @@ function TimelineGanttViewInner({
saveZoom.mutate({ level });
}
useEffect(() => {
registerSection({ id: sectionId, label: sectionLabel, ref: sectionRef, projectId });
return () => unregisterSection(sectionId);
}, [sectionId, sectionLabel, projectId, registerSection, unregisterSection]);
useEffect(() => {
const el = scrollContainerRef.current;
if (!el) return;
@@ -236,7 +224,6 @@ function TimelineGanttViewInner({
return (
<div
ref={sectionRef}
data-ai-section={sectionId}
className={cn('@container flex flex-col gap-4 w-full', className)}
>
{/* Header: Legend + Actions */}

View File

@@ -0,0 +1,304 @@
import { useEffect, useId, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CalendarIcon } from 'lucide-react';
import { useFormatPrefs, formatDate, type FormatPrefs } from '@/lib/date';
import { parseDate, type DateKeywords } from '@/lib/parseDate';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
const MINUTES = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'];
function formatValue(d: Date, prefs: FormatPrefs, withTime: boolean): string {
const base = formatDate(d.getTime(), prefs);
if (!withTime) return base;
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
if (h === '00' && m === '00') return base;
return `${base} ${h}:${m}`;
}
function CalendarTimeBody({
value,
onChange,
onCommit,
withTime,
minDate,
onAfterPick,
}: {
value: Date | undefined;
onChange: (d: Date | undefined) => void;
onCommit?: (d: Date) => void;
withTime: boolean;
minDate?: Date;
onAfterPick: () => void;
}) {
const dueHour = value ? String(value.getHours()).padStart(2, '0') : '';
const dueMinute = value ? String(value.getMinutes()).padStart(2, '0') : '';
function applyTime(h: string, m: string) {
if (!value) return;
const next = new Date(value);
next.setHours(parseInt(h, 10), parseInt(m, 10), 0, 0);
onChange(next);
onCommit?.(next);
}
return (
<>
<Calendar
mode="single"
selected={value}
onSelect={(d) => {
if (d) {
const next = new Date(d);
if (value && withTime) {
next.setHours(value.getHours(), value.getMinutes(), 0, 0);
}
onChange(next);
onCommit?.(next);
}
onAfterPick();
}}
disabled={minDate ? { before: minDate } : undefined}
/>
{withTime && (
<div className="border-t px-3 py-2 flex items-center gap-1.5">
<Select value={dueHour} onValueChange={(h) => applyTime(h, dueMinute || '00')} disabled={!value}>
<SelectTrigger className="h-8 w-20 text-sm"><SelectValue placeholder="HH" /></SelectTrigger>
<SelectContent>
{HOURS.map((h) => <SelectItem key={h} value={h}>{h}</SelectItem>)}
</SelectContent>
</Select>
<span className="text-muted-foreground text-sm">:</span>
<Select
value={dueMinute && MINUTES.includes(dueMinute) ? dueMinute : ''}
onValueChange={(m) => applyTime(dueHour || '00', m)}
disabled={!value}
>
<SelectTrigger className="h-8 w-20 text-sm"><SelectValue placeholder="MM" /></SelectTrigger>
<SelectContent>
{MINUTES.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}
</SelectContent>
</Select>
</div>
)}
</>
);
}
export type DateFieldProps = {
value: Date | undefined;
onChange: (d: Date | undefined) => void;
onCommit?: (d: Date) => void;
placeholder?: string;
minDate?: Date;
autoFocus?: boolean;
invalidMessage?: string;
className?: string;
'aria-label'?: string;
id?: string;
withTime?: boolean;
flat?: boolean;
};
export function DateField({
value,
onChange,
onCommit,
placeholder,
minDate,
autoFocus,
invalidMessage,
className,
id,
withTime,
flat,
...rest
}: DateFieldProps) {
const reactId = useId();
const fieldId = id ?? reactId;
const errorId = `${fieldId}-error`;
const { t, i18n } = useTranslation();
const prefs = useFormatPrefs();
const [text, setText] = useState<string>(value ? formatValue(value, prefs, !!withTime) : '');
const [focused, setFocused] = useState(false);
const [invalid, setInvalid] = useState(false);
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!focused) {
setText(value ? formatValue(value, prefs, !!withTime) : '');
setInvalid(false);
}
}, [value, focused, prefs, withTime]);
function getKeywords(): DateKeywords {
const today = i18n.t('date.keyword.today', { returnObjects: true }) as unknown;
const tomorrow = i18n.t('date.keyword.tomorrow', { returnObjects: true }) as unknown;
const yesterday = i18n.t('date.keyword.yesterday', { returnObjects: true }) as unknown;
const weekdays = i18n.t('date.keyword.weekdays', { returnObjects: true }) as unknown;
return {
today: Array.isArray(today) ? (today as string[]) : ['today'],
tomorrow: Array.isArray(tomorrow) ? (tomorrow as string[]) : ['tomorrow'],
yesterday: Array.isArray(yesterday) ? (yesterday as string[]) : ['yesterday'],
weekdays: Array.isArray(weekdays)
? (weekdays as string[][])
: [['sun'],['mon'],['tue'],['wed'],['thu'],['fri'],['sat']],
};
}
function tryParse(raw: string): Date | null {
const parsed = parseDate(raw, prefs, getKeywords());
if (!parsed) return null;
if (minDate && parsed < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())) {
return null;
}
return parsed;
}
function commit(raw: string, fireCommit: boolean) {
if (!raw.trim()) {
onChange(undefined);
setInvalid(false);
return;
}
const parsed = tryParse(raw);
if (parsed) {
setInvalid(false);
onChange(parsed);
if (fireCommit) onCommit?.(parsed);
} else {
setInvalid(true);
}
}
if (flat) {
return (
<div className={cn('relative', className)}>
<Input
ref={inputRef}
id={fieldId}
autoFocus={autoFocus}
placeholder={placeholder ?? t('timeline.pickDate')}
value={text}
onChange={(e) => { setText(e.target.value); setInvalid(false); }}
onFocus={() => setFocused(true)}
onBlur={() => { setFocused(false); commit(text, false); }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commit(text, true);
}
}}
aria-invalid={invalid || !!invalidMessage}
aria-describedby={invalidMessage ? errorId : undefined}
aria-label={rest['aria-label']}
className={cn((invalid || !!invalidMessage) && 'ring-1 ring-destructive')}
/>
<div className="mt-2 rounded-md border">
<CalendarTimeBody
value={value}
onChange={(d) => {
onChange(d);
if (d) {
setText(formatValue(d, prefs, !!withTime));
setInvalid(false);
}
}}
onCommit={onCommit}
withTime={!!withTime}
minDate={minDate}
onAfterPick={() => { inputRef.current?.focus(); }}
/>
</div>
{invalidMessage && (
<p id={errorId} className="mt-1 text-xs text-destructive">{invalidMessage}</p>
)}
</div>
);
}
return (
<div className={cn('relative', className)}>
<Input
ref={inputRef}
id={fieldId}
autoFocus={autoFocus}
placeholder={placeholder ?? t('timeline.pickDate')}
value={text}
onChange={(e) => {
setText(e.target.value);
setInvalid(false);
}}
onFocus={() => setFocused(true)}
onBlur={() => {
setFocused(false);
commit(text, false);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commit(text, true);
} else if (e.altKey && e.key === 'ArrowDown') {
e.preventDefault();
setOpen(true);
}
}}
aria-invalid={invalid || !!invalidMessage}
aria-describedby={invalidMessage ? errorId : undefined}
aria-label={rest['aria-label']}
className={cn('pr-8', (invalid || !!invalidMessage) && 'ring-1 ring-destructive')}
/>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full w-8 text-muted-foreground hover:text-foreground"
aria-label={t('timeline.pickDate')}
tabIndex={-1}
onMouseDown={(e) => e.preventDefault()}
>
<CalendarIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<CalendarTimeBody
value={value}
onChange={(d) => {
onChange(d);
if (d) {
setText(formatValue(d, prefs, !!withTime));
setInvalid(false);
}
}}
onCommit={onCommit}
withTime={!!withTime}
minDate={minDate}
onAfterPick={() => {
setOpen(false);
inputRef.current?.focus();
}}
/>
</PopoverContent>
</Popover>
{invalidMessage && (
<p id={errorId} className="mt-1 text-xs text-destructive">{invalidMessage}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,342 @@
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
import { useFormatPrefs, type FormatPrefs } from '@/lib/date';
import { Calendar } from '@/components/ui/calendar';
import { cn } from '@/lib/utils';
export interface DateTimeFieldProps {
value: Date | undefined;
onChange: (d: Date | undefined) => void;
onCommit?: (d: Date) => void;
withTime?: boolean;
className?: string;
'aria-label'?: string;
}
type SegKey = 'day' | 'month' | 'year' | 'hour' | 'minute';
interface SegDef {
key: SegKey;
len: number;
min: number;
max: number;
placeholder: string;
}
const SEGS: Record<SegKey, SegDef> = {
day: { key: 'day', len: 2, min: 1, max: 31, placeholder: 'DD' },
month: { key: 'month', len: 2, min: 1, max: 12, placeholder: 'MM' },
year: { key: 'year', len: 4, min: 1900, max: 2100, placeholder: 'YYYY' },
hour: { key: 'hour', len: 2, min: 0, max: 23, placeholder: 'HH' },
minute: { key: 'minute', len: 2, min: 0, max: 59, placeholder: 'mm' },
};
type LayoutEntry = { seg: SegKey; sep: string | null };
function layoutForFormat(fmt: FormatPrefs['dateFormat']): LayoutEntry[] {
switch (fmt) {
case 'MM/dd/yyyy': return [{ seg: 'month', sep: '/' }, { seg: 'day', sep: '/' }, { seg: 'year', sep: null }];
case 'yyyy-MM-dd': return [{ seg: 'year', sep: '-' }, { seg: 'month', sep: '-' }, { seg: 'day', sep: null }];
default: return [{ seg: 'day', sep: '/' }, { seg: 'month', sep: '/' }, { seg: 'year', sep: null }];
}
}
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n));
}
type SegState = Record<SegKey, string>;
function fromDate(d: Date | undefined): SegState {
if (!d) return { day: '', month: '', year: '', hour: '', minute: '' };
return {
day: String(d.getDate()).padStart(2, '0'),
month: String(d.getMonth() + 1).padStart(2, '0'),
year: String(d.getFullYear()),
hour: String(d.getHours()).padStart(2, '0'),
minute: String(d.getMinutes()).padStart(2, '0'),
};
}
function toDate(state: SegState, withTime: boolean): Date | undefined {
const d = parseInt(state.day, 10);
const m = parseInt(state.month, 10);
const y = parseInt(state.year, 10);
if (!d || !m || !y) return undefined;
if (y < 1900 || y > 2100) return undefined;
const h = withTime && state.hour !== '' ? parseInt(state.hour, 10) : 0;
const mn = withTime && state.minute !== '' ? parseInt(state.minute, 10) : 0;
const dt = new Date(y, m - 1, d, h, mn, 0, 0);
if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return undefined;
return dt;
}
export function DateTimeField({
value,
onChange,
onCommit,
withTime = false,
className,
...rest
}: DateTimeFieldProps) {
const prefs = useFormatPrefs();
const dateLayout = useMemo(() => layoutForFormat(prefs.dateFormat), [prefs.dateFormat]);
const order: SegKey[] = useMemo(() => {
const base = dateLayout.map((l) => l.seg);
return withTime ? [...base, 'hour', 'minute'] : base;
}, [dateLayout, withTime]);
const [seg, setSeg] = useState<SegState>(() => fromDate(value));
const refs = useRef<Record<SegKey, HTMLSpanElement | null>>({
day: null, month: null, year: null, hour: null, minute: null,
});
// Stable per-segment ref setters (avoid new-function-per-render).
const refSetters = useRef<Record<SegKey, (el: HTMLSpanElement | null) => void>>({
day: (el) => { refs.current.day = el; },
month: (el) => { refs.current.month = el; },
year: (el) => { refs.current.year = el; },
hour: (el) => { refs.current.hour = el; },
minute: (el) => { refs.current.minute = el; },
});
function focusSeg(key: SegKey) {
const el = refs.current[key];
if (!el) return;
el.focus();
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
// Note: typing updates LOCAL state only. We deliberately don't call
// onChange on every keystroke — otherwise the parent re-renders on each
// keypress, which re-renders the (heavy) Calendar grid and the rest of
// TaskFormDialog. onChange only fires on commit (Enter) or calendar pick.
// Stable across renders: uses functional setSeg, refs, and order via ref.
const orderRef = useRef(order);
orderRef.current = order;
const withTimeRef = useRef(withTime);
withTimeRef.current = withTime;
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const onCommitRef = useRef(onCommit);
onCommitRef.current = onCommit;
const onSegKeyDown = useCallback((e: ReactKeyboardEvent<HTMLSpanElement>, key: SegKey) => {
const def = SEGS[key];
if (e.key === 'ArrowRight') {
e.preventDefault();
const idx = orderRef.current.indexOf(key);
const nxt = orderRef.current[idx + 1];
if (nxt) focusSeg(nxt);
return;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
const idx = orderRef.current.indexOf(key);
const prv = orderRef.current[idx - 1];
if (prv) focusSeg(prv);
return;
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
setSeg((prev) => {
const cur = prev[key];
const delta = e.key === 'ArrowUp' ? 1 : -1;
const base = cur === '' ? (key === 'hour' || key === 'minute' ? 0 : def.min) : parseInt(cur, 10);
let n = base + delta;
if (n < def.min) n = def.max;
if (n > def.max) n = def.min;
return { ...prev, [key]: String(n).padStart(def.len, '0') };
});
return;
}
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
setSeg((prev) => {
if (prev[key] === '') {
const idx = orderRef.current.indexOf(key);
const prv = orderRef.current[idx - 1];
if (prv) focusSeg(prv);
return prev;
}
return { ...prev, [key]: '' };
});
return;
}
if (/^[0-9]$/.test(e.key)) {
e.preventDefault();
let advance = false;
setSeg((prev) => {
const cur = prev[key];
const incoming = cur.length >= def.len ? e.key : cur + e.key;
const numeric = parseInt(incoming, 10);
const final = numeric > def.max ? e.key : incoming;
const padded = final.padStart(Math.min(final.length, def.len), '0');
if (padded.length >= def.len || parseInt(padded, 10) * 10 > def.max) {
advance = true;
}
return { ...prev, [key]: padded };
});
if (advance) {
const idx = orderRef.current.indexOf(key);
const nxt = orderRef.current[idx + 1];
if (nxt) focusSeg(nxt);
}
return;
}
if (e.key === 'Enter') {
e.preventDefault();
// Read current seg via functional updater; commit then propagate.
setSeg((prev) => {
const today = new Date();
const wt = withTimeRef.current;
const filled: SegState = {
day: prev.day || String(today.getDate()).padStart(2, '0'),
month: prev.month || String(today.getMonth() + 1).padStart(2, '0'),
year: prev.year || String(today.getFullYear()),
hour: wt ? (prev.hour || '00') : '00',
minute: wt ? (prev.minute || '00') : '00',
};
const monthN = clamp(parseInt(filled.month, 10), SEGS.month.min, SEGS.month.max);
const yearN = clamp(parseInt(filled.year, 10), SEGS.year.min, SEGS.year.max);
const hourN = clamp(parseInt(filled.hour, 10), SEGS.hour.min, SEGS.hour.max);
const minuteN = clamp(parseInt(filled.minute, 10), SEGS.minute.min, SEGS.minute.max);
const lastDayOfMonth = new Date(yearN, monthN, 0).getDate();
const dayN = clamp(parseInt(filled.day, 10), SEGS.day.min, lastDayOfMonth);
const dt = new Date(yearN, monthN - 1, dayN, hourN, minuteN, 0, 0);
onChangeRef.current(dt);
onCommitRef.current?.(dt);
return fromDate(dt);
});
return;
}
if (e.key === '/' || e.key === '-' || e.key === ':' || e.key === ' ') {
e.preventDefault();
const idx = orderRef.current.indexOf(key);
const nxt = orderRef.current[idx + 1];
if (nxt) focusSeg(nxt);
return;
}
}, []);
const onCalendarSelect = useCallback((d: Date | undefined) => {
if (!d) return;
setSeg((prev) => {
const next: SegState = {
...prev,
day: String(d.getDate()).padStart(2, '0'),
month: String(d.getMonth() + 1).padStart(2, '0'),
year: String(d.getFullYear()),
};
const dt = toDate(next, withTime);
if (dt) onChange(dt);
return next;
});
}, [withTime, onChange]);
const selectedDate = toDate(seg, withTime);
const selectedMs = selectedDate ? selectedDate.getTime() : null;
const calendarEl = useMemo(
() => (
<Calendar
mode="single"
selected={selectedDate}
onSelect={onCalendarSelect}
/>
),
// selectedMs primary key; selectedDate/onCalendarSelect captured for closure.
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedMs, onCalendarSelect],
);
return (
<div className={cn('flex flex-col gap-3', className)} aria-label={rest['aria-label']}>
<div
className="inline-flex items-center gap-0.5 rounded-md border border-input bg-background px-3 py-2 text-sm font-mono focus-within:ring-2 focus-within:ring-ring/30 focus-within:border-ring"
role="group"
>
{dateLayout.map(({ seg: sk, sep }) => (
<SegmentSpan
key={sk}
segKey={sk}
value={seg[sk]}
onKeyDown={onSegKeyDown}
registerRef={refSetters.current[sk]}
sep={sep}
/>
))}
{withTime && (
<>
<span className="px-1.5 text-muted-foreground/60 select-none">&nbsp;</span>
<SegmentSpan
segKey="hour"
value={seg.hour}
onKeyDown={onSegKeyDown}
registerRef={refSetters.current.hour}
sep=":"
/>
<SegmentSpan
segKey="minute"
value={seg.minute}
onKeyDown={onSegKeyDown}
registerRef={refSetters.current.minute}
sep={null}
/>
</>
)}
</div>
<div className="rounded-md border">{calendarEl}</div>
</div>
);
}
const SegmentSpan = memo(function SegmentSpan({
segKey,
value,
onKeyDown,
registerRef,
sep,
}: {
segKey: SegKey;
value: string;
onKeyDown: (e: ReactKeyboardEvent<HTMLSpanElement>, key: SegKey) => void;
registerRef: (el: HTMLSpanElement | null) => void;
sep: string | null;
}) {
const def = SEGS[segKey];
const isEmpty = value === '';
return (
<>
<span
ref={registerRef}
tabIndex={0}
role="spinbutton"
aria-label={def.placeholder}
aria-valuemin={def.min}
aria-valuemax={def.max}
aria-valuenow={isEmpty ? undefined : clamp(parseInt(value, 10), def.min, def.max)}
onKeyDown={(e) => onKeyDown(e, segKey)}
onFocus={(e) => {
const range = document.createRange();
range.selectNodeContents(e.currentTarget);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}}
className={cn(
'inline-block text-center outline-none rounded px-0.5 cursor-text tabular-nums',
def.len === 4 ? 'min-w-[3.5ch]' : 'min-w-[1.8ch]',
isEmpty && 'text-muted-foreground/60',
'focus:bg-accent',
)}
>
{isEmpty ? def.placeholder : value}
</span>
{sep && <span className="text-muted-foreground/70 select-none px-0.5">{sep}</span>}
</>
);
});

View File

@@ -59,7 +59,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] sm:max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none",
className
)}
{...props}

View File

@@ -0,0 +1,279 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { trpc } from '@/lib/trpc';
import type { ChatMessage } from '@/hooks/useAIChat';
export interface ContextualScope {
page: 'timeline' | 'tasks' | 'projects-list' | 'project' | 'note';
entityType?: 'project' | 'note' | null;
entityId?: string;
entityName?: string;
projectId?: string | null;
counts?: { tasks?: number; notes?: number; milestones?: number };
charCount?: number;
filters?: unknown;
}
interface ContextualChatState {
open: boolean;
size: number;
sessionId: string | null;
scope: ContextualScope | null;
messages: ChatMessage[];
isStreaming: boolean;
streamingContent: string;
toggle: () => void;
close: () => void;
newChat: () => Promise<void>;
setSize: (s: number) => void;
setScope: (s: ContextualScope) => void;
send: (text: string) => void;
}
const Ctx = createContext<ContextualChatState | null>(null);
const SESSION_KEY = 'chat.contextual.lastSessionId';
const SIZE_KEY = 'chat.sidebar.size';
const OPEN_KEY = 'chat.contextual.open';
const SIZE_MIN = 22;
const SIZE_MAX = 60;
const SIZE_DEFAULT = 38;
function clamp(n: number, min: number, max: number): number {
return Math.max(min, Math.min(max, n));
}
function readNumber(k: string, fallback: number): number {
if (typeof window === 'undefined') return fallback;
const v = window.localStorage.getItem(k);
if (!v) return fallback;
const n = Number(v);
if (!Number.isFinite(n)) return fallback;
// Defensive: clamp stale or out-of-range persisted values.
return clamp(n, SIZE_MIN, SIZE_MAX);
}
export function ContextualChatProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState<boolean>(() =>
typeof window !== 'undefined' && window.localStorage.getItem(OPEN_KEY) === '1',
);
const [size, setSizeState] = useState<number>(() => readNumber(SIZE_KEY, SIZE_DEFAULT));
const [sessionId, setSessionId] = useState<string | null>(() =>
typeof window !== 'undefined' ? window.localStorage.getItem(SESSION_KEY) : null,
);
const [scope, setScopeState] = useState<ContextualScope | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const streamRef = useRef('');
const activeUnsubRef = useRef<(() => void) | null>(null);
const utils = trpc.useUtils();
const createSession = trpc.aiChat.createSession.useMutation();
const appendMessage = trpc.aiChat.appendMessage.useMutation();
const chatMutation = trpc.ai.chat.useMutation();
// Hydrate or create session on mount. One-shot effect.
const hydratedRef = useRef(false);
useEffect(() => {
if (hydratedRef.current) return;
hydratedRef.current = true;
let cancelled = false;
(async () => {
if (!sessionId) {
const { id } = await createSession.mutateAsync({ channel: 'contextual' });
if (cancelled) return;
window.localStorage.setItem(SESSION_KEY, id);
setSessionId(id);
} else {
const res = await utils.aiChat.getSession.fetch({ id: sessionId });
if (cancelled) return;
if (!res) {
const { id } = await createSession.mutateAsync({ channel: 'contextual' });
if (cancelled) return;
window.localStorage.setItem(SESSION_KEY, id);
setSessionId(id);
} else if (res.messages) {
setMessages(
res.messages
.filter((m) => m.role !== 'system')
.map((m) => ({
id: m.id,
role: m.role as 'user' | 'assistant',
content: m.content,
})),
);
}
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const setSize = useCallback((s: number) => {
const clamped = clamp(s, SIZE_MIN, SIZE_MAX);
setSizeState(clamped);
window.localStorage.setItem(SIZE_KEY, String(clamped));
}, []);
const toggle = useCallback(() => {
setOpen((o) => {
const next = !o;
window.localStorage.setItem(OPEN_KEY, next ? '1' : '0');
return next;
});
}, []);
const close = useCallback(() => {
setOpen(false);
window.localStorage.setItem(OPEN_KEY, '0');
}, []);
const newChat = useCallback(async () => {
const { id } = await createSession.mutateAsync({ channel: 'contextual' });
window.localStorage.setItem(SESSION_KEY, id);
setSessionId(id);
setMessages([]);
}, [createSession]);
const lastScopeKeyRef = useRef<string>('');
const setScope = useCallback(
(s: ContextualScope) => {
const key = JSON.stringify(s);
if (key === lastScopeKeyRef.current) return;
lastScopeKeyRef.current = key;
setScopeState(s);
if (sessionId && (window as any).electronAI?.sendContextualScopeUpdate) {
// Best-effort fire — exposed by preload in M4.7.
(window as any).electronAI.sendContextualScopeUpdate({ sessionId, scope: s });
}
},
[sessionId],
);
const send = useCallback(
(text: string) => {
if (!sessionId || !scope) return;
const trimmed = text.trim();
if (!trimmed || isStreaming) return;
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content: trimmed,
};
setMessages((prev) => [...prev, userMsg]);
appendMessage.mutate({
sessionId,
role: 'user',
content: trimmed,
scope: JSON.stringify(scope),
});
const requestId = crypto.randomUUID();
setIsStreaming(true);
setStreamingContent('');
streamRef.current = '';
const unsub = window.electronAI.onStreamEvent((event) => {
if (event.requestId !== requestId) return;
switch (event.type) {
case 'stream_text':
streamRef.current += event.chunk;
setStreamingContent(streamRef.current);
break;
case 'stream_end': {
const final = streamRef.current;
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: final },
]);
appendMessage.mutate({
sessionId,
role: 'assistant',
content: final,
scope: JSON.stringify(scope),
});
setStreamingContent('');
streamRef.current = '';
setIsStreaming(false);
unsub();
activeUnsubRef.current = null;
break;
}
}
});
activeUnsubRef.current = unsub;
chatMutation.mutate(
{
requestId,
message: trimmed,
conversationHistory: messages.slice(-20).map((m) => ({
role: m.role,
content: m.content,
})),
sessionId,
mode: 'contextual',
scope,
},
{
onError: (err) => {
unsub();
activeUnsubRef.current = null;
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: 'assistant',
content: err.message || 'An unexpected error occurred.',
error: true,
},
]);
setStreamingContent('');
streamRef.current = '';
setIsStreaming(false);
},
},
);
},
[sessionId, scope, isStreaming, messages, appendMessage, chatMutation],
);
// Unmount cleanup: unsubscribe any in-flight stream listener.
useEffect(() => {
return () => {
activeUnsubRef.current?.();
activeUnsubRef.current = null;
};
}, []);
const value = useMemo<ContextualChatState>(
() => ({
open,
size,
sessionId,
scope,
messages,
isStreaming,
streamingContent,
toggle,
close,
newChat,
setSize,
setScope,
send,
}),
[open, size, sessionId, scope, messages, isStreaming, streamingContent, toggle, close, newChat, setSize, setScope, send],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useContextualChat() {
const v = useContext(Ctx);
if (!v) throw new Error('useContextualChat must be used within ContextualChatProvider');
return v;
}

View File

@@ -1,262 +0,0 @@
import {
createContext,
useContext,
useCallback,
useState,
useRef,
type ReactNode,
type RefObject,
} from 'react';
// ---------- Types ----------
interface AISection {
id: string; // e.g. "project-tasks", "tasks-list", "timeline-chart"
label: string; // Human-readable, e.g. "Tasks", "Project Timeline"
ref: RefObject<HTMLElement | null>;
projectId?: string; // If section is project-scoped
anchorMode?: 'top-right' | 'right-margin'; // default: 'top-right'
}
interface SectionOpenOpts {
clickY?: number; // For right-margin mode: Y-coordinate of the double-click
}
interface FloatingChatState {
isOpen: boolean;
activeSectionId: string | null;
position: { x: number; y: number; width: number };
morphTargetId: string | null;
projectId?: string;
pendingSection?: { sectionId: string; clickY?: number }; // For cross-page navigation
}
interface FloatingChatContextValue {
// State
state: FloatingChatState;
sections: Map<string, AISection>;
// Section registry
registerSection: (section: AISection) => void;
unregisterSection: (id: string) => void;
// Actions
openAtSection: (sectionId: string, opts?: SectionOpenOpts) => void;
moveToSection: (sectionId: string, opts?: SectionOpenOpts) => void;
close: () => void;
setMorphTarget: (id: string | null) => void;
updatePosition: (pos: { x: number; y: number; width: number }) => void;
setPendingSection: (pending: { sectionId: string; clickY?: number } | undefined) => void;
}
// ---------- Constants ----------
/** Dynamic chat width: 35% of viewport, clamped between 320px and 520px. */
export function getChatWidth(): number {
return Math.min(630, Math.max(320, Math.round(window.innerWidth * 0.35)));
}
export const CHAT_HEIGHT = 420;
export const PADDING = 16;
// ---------- Position computation ----------
function clampPosition(x: number, y: number): { x: number; y: number } {
const w = getChatWidth();
return {
x: Math.max(PADDING, Math.min(x, window.innerWidth - w - PADDING)),
y: Math.max(PADDING, Math.min(y, window.innerHeight - CHAT_HEIGHT - PADDING)),
};
}
function computeAnchorPosition(
section: AISection,
opts?: SectionOpenOpts,
): { x: number; y: number; width: number } {
const el = section.ref.current;
const w = getChatWidth();
if (!el) return { x: PADDING, y: PADDING, width: w };
const rect = el.getBoundingClientRect();
const mode = section.anchorMode ?? 'top-right';
if (mode === 'right-margin') {
// Position to the right of the section at the click Y-coordinate
const rawX = rect.right + PADDING;
const rawY = opts?.clickY ?? rect.top + PADDING;
const { x, y } = clampPosition(rawX, rawY);
return { x, y, width: w };
}
// Default: top-right of section
const rawX = rect.right - w - PADDING;
const rawY = rect.top + PADDING;
const { x, y } = clampPosition(rawX, rawY);
return { x, y, width: w };
}
/**
* Dual-anchor recomputation for scroll tracking.
* Returns null when the section is fully off-screen (freeze at last position).
*/
export function computeDualAnchor(
section: AISection,
): { x: number; y: number; width: number } | null {
const el = section.ref.current;
if (!el) return null;
// Skip scroll tracking for right-margin mode (stays at fixed clickY)
if (section.anchorMode === 'right-margin') return null;
const rect = el.getBoundingClientRect();
const w = getChatWidth();
// Fully off-screen — freeze
if (rect.bottom < 0 || rect.top > window.innerHeight) return null;
// Primary anchor: top-right (when section top is visible)
if (rect.top >= PADDING) {
const { x, y } = clampPosition(
rect.right - w - PADDING,
rect.top + PADDING,
);
return { x, y, width: w };
}
// Fallback anchor: bottom-right (when section top scrolled off)
if (rect.bottom > CHAT_HEIGHT) {
const { x, y } = clampPosition(
rect.right - w - PADDING,
rect.bottom - CHAT_HEIGHT - PADDING,
);
return { x, y, width: w };
}
// Section visible but too small for fallback — clamp to top
const { x, y } = clampPosition(
rect.right - w - PADDING,
PADDING,
);
return { x, y, width: w };
}
// ---------- Context ----------
const FloatingChatCtx = createContext<FloatingChatContextValue | null>(null);
export function useFloatingChat(): FloatingChatContextValue {
const ctx = useContext(FloatingChatCtx);
if (!ctx)
throw new Error('useFloatingChat must be used within FloatingChatProvider');
return ctx;
}
// ---------- Provider ----------
export function FloatingChatProvider({ children }: { children: ReactNode }) {
const sectionsRef = useRef<Map<string, AISection>>(new Map());
const [sections, setSections] = useState<Map<string, AISection>>(new Map());
const [state, setState] = useState<FloatingChatState>({
isOpen: false,
activeSectionId: null,
position: { x: 0, y: 0, width: getChatWidth() },
morphTargetId: null,
});
const registerSection = useCallback((section: AISection) => {
sectionsRef.current.set(section.id, section);
setSections(new Map(sectionsRef.current));
// Check if there's a pending section to open after cross-page navigation
setState((prev) => {
if (prev.pendingSection && prev.pendingSection.sectionId === section.id) {
const position = computeAnchorPosition(section, { clickY: prev.pendingSection.clickY });
return {
...prev,
isOpen: true,
activeSectionId: section.id,
position,
morphTargetId: null,
projectId: section.projectId,
pendingSection: undefined,
};
}
return prev;
});
}, []);
const unregisterSection = useCallback((id: string) => {
sectionsRef.current.delete(id);
setSections(new Map(sectionsRef.current));
}, []);
const openAtSection = useCallback((sectionId: string, opts?: SectionOpenOpts) => {
const section = sectionsRef.current.get(sectionId);
if (!section) return;
const position = computeAnchorPosition(section, opts);
setState({
isOpen: true,
activeSectionId: sectionId,
position,
morphTargetId: null,
projectId: section.projectId,
});
}, []);
const moveToSection = useCallback((sectionId: string, opts?: SectionOpenOpts) => {
const section = sectionsRef.current.get(sectionId);
if (!section) return;
const position = computeAnchorPosition(section, opts);
setState((prev) => ({
...prev,
activeSectionId: sectionId,
position,
projectId: section.projectId,
}));
}, []);
const close = useCallback(() => {
setState((prev) => ({
...prev,
isOpen: false,
activeSectionId: null,
morphTargetId: null,
}));
}, []);
const setMorphTarget = useCallback((id: string | null) => {
setState((prev) => ({ ...prev, morphTargetId: id }));
}, []);
const updatePosition = useCallback((pos: { x: number; y: number; width: number }) => {
setState((prev) => ({ ...prev, position: pos }));
}, []);
const setPendingSection = useCallback((pending: { sectionId: string; clickY?: number } | undefined) => {
setState((prev) => ({ ...prev, pendingSection: pending }));
}, []);
return (
<FloatingChatCtx.Provider
value={{
state,
sections,
registerSection,
unregisterSection,
openAtSection,
moveToSection,
close,
setMorphTarget,
updatePosition,
setPendingSection,
}}
>
{children}
</FloatingChatCtx.Provider>
);
}

View File

@@ -0,0 +1,40 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
interface HeaderContextValue {
label: string | null;
extras: ReactNode;
/** Replaces the page-label slot. When set, no h4 is rendered. */
leftExtras: ReactNode;
/** Rendered between the flex-1 spacer and the AdiuvaTriggerButton. */
rightExtras: ReactNode;
setLabel: (label: string | null) => void;
setExtras: (extras: ReactNode) => void;
setLeftExtras: (extras: ReactNode) => void;
setRightExtras: (extras: ReactNode) => void;
}
const HeaderContext = createContext<HeaderContextValue | null>(null);
export function HeaderProvider({ children }: { children: ReactNode }) {
const [label, setLabelState] = useState<string | null>(null);
const [extras, setExtrasState] = useState<ReactNode>(null);
const [leftExtras, setLeftExtrasState] = useState<ReactNode>(null);
const [rightExtras, setRightExtrasState] = useState<ReactNode>(null);
const setLabel = useCallback((l: string | null) => setLabelState(l), []);
const setExtras = useCallback((e: ReactNode) => setExtrasState(e), []);
const setLeftExtras = useCallback((e: ReactNode) => setLeftExtrasState(e), []);
const setRightExtras = useCallback((e: ReactNode) => setRightExtrasState(e), []);
return (
<HeaderContext.Provider value={{ label, extras, leftExtras, rightExtras, setLabel, setExtras, setLeftExtras, setRightExtras }}>
{children}
</HeaderContext.Provider>
);
}
export function useHeader() {
const ctx = useContext(HeaderContext);
if (!ctx) throw new Error('useHeader must be used inside HeaderProvider');
return ctx;
}

View File

@@ -365,3 +365,78 @@ body {
--crepe-shadow-1: 0px 1px 2px 0px rgba(255, 255, 255, 0.3), 0px 1px 3px 1px rgba(255, 255, 255, 0.15);
--crepe-shadow-2: 0px 1px 2px 0px rgba(255, 255, 255, 0.3), 0px 2px 6px 2px rgba(255, 255, 255, 0.15);
}
/* ---------------------------------------------------------------------------
* Adiuva trigger button + compass icon
* --------------------------------------------------------------------------- */
.adiuva-btn {
position: relative;
width: 48px;
height: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
/* Light mode: pure white pops on pinkish canvas (#f4edf3). */
background: #ffffff;
border: 1px solid #c8c3cd;
border-radius: 14px;
cursor: pointer;
transition: box-shadow .25s ease, background .2s ease, border-color .2s ease;
/* Centered ambient shadow, not bottom-weighted. */
box-shadow:
0 0 0 1px rgba(0, 0, 0, .02),
0 2px 8px -2px rgba(0, 0, 0, .08),
0 6px 16px -4px rgba(0, 0, 0, .06);
color: inherit;
padding: 0;
}
.adiuva-btn:hover {
background: #ffffff;
border-color: color-mix(in srgb, #c8c3cd 50%, #fbc881 50%);
box-shadow:
0 0 0 1px rgba(251, 200, 129, .25),
0 2px 10px -2px rgba(0, 0, 0, .10),
0 8px 22px -4px rgba(251, 200, 129, .22);
}
.adiuva-btn:active { transform: scale(.97); }
.adiuva-btn.sm { width: 40px; height: 40px; border-radius: 12px; }
/* Dark mode: surface needs to lift off near-black canvas (#0c0c0c). */
.dark .adiuva-btn {
background: #1f1f22;
border-color: rgba(255, 255, 255, .08);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, .05),
0 0 0 1px rgba(0, 0, 0, .35),
0 4px 14px -2px rgba(0, 0, 0, .55),
0 10px 24px -6px rgba(0, 0, 0, .45);
}
.dark .adiuva-btn:hover {
background: #26262a;
border-color: rgba(251, 200, 129, .18);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, .06),
0 0 0 1px rgba(251, 200, 129, .12),
0 4px 18px -2px rgba(0, 0, 0, .6),
0 12px 28px -8px rgba(251, 200, 129, .25);
}
/* The asset SVG already animates internally — img tag uses its own keyframes.
These external keyframes remain only for the older inline-SVG path (kept
for back-compat if any consumer still uses <AdiuvaIcon> inline). */
.adiuva-needle-g {
transform-origin: 32px 32px;
animation: adiuva-compass-settle 6s ease-in-out infinite;
}
@keyframes adiuva-compass-settle {
0% { transform: rotate(0deg); }
20% { transform: rotate(4deg); }
50% { transform: rotate(-3deg); }
80% { transform: rotate(2deg); }
100% { transform: rotate(0deg); }
}
@media (prefers-reduced-motion: reduce) {
.adiuva-needle-g { animation: none; }
.adiuva-mark-img { animation: none; }
}

View File

@@ -1,33 +1,16 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { trpc } from '@/lib/trpc';
export type FloatingDomainSignal =
| 'tasks'
| 'notes'
| 'timelines'
| 'projects'
| {
type: 'task' | 'timeline' | 'project' | 'note' | 'node';
id?: string | null;
section?: 'task' | 'timeline' | 'note' | null;
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* Renderer-only context describing where the user is in the UI.
* Retained for call-site compatibility; mode/scope fields support v3 routing.
*/
export interface UIChatContext {
type: 'global' | 'project' | 'floating';
type: 'global' | 'project';
projectId?: string;
/** For floating mode — the entity scope to pass to the backend. */
scope?: {
type: 'task' | 'project' | 'note' | 'timeline';
id?: string;
};
}
export interface ChatMessage {
@@ -47,10 +30,6 @@ interface UseAIChatReturn {
cacheKey: string;
}
interface UseAIChatOptions {
onDomainSignal?: (domain: FloatingDomainSignal) => void;
}
interface CachedChatState {
messages: ChatMessage[];
/** Written by ChatInputBox; read on mount to restore draft. Not written by this hook. */
@@ -62,11 +41,7 @@ const chatSessionCache = new Map<string, CachedChatState>();
function getContextCacheKey(ctx: UIChatContext): string {
if (ctx.type === 'global') return 'global';
if (ctx.type === 'project') return `project:${ctx.projectId ?? ''}`;
// Floating chat should keep a single continuous session while the panel is open,
// even when route/section context changes due floating-domain navigation.
return 'floating';
return `project:${ctx.projectId ?? ''}`;
}
// ---------------------------------------------------------------------------
@@ -98,7 +73,7 @@ const TABLE_TO_ENTITY: Record<string, 'task' | 'project' | 'note' | 'timeline'>
timelineEvents: 'timeline',
};
function parseMutationsToEntityTags(mutations: unknown[] | undefined): string {
export function parseMutationsToEntityTags(mutations: unknown[] | undefined): string {
if (!Array.isArray(mutations)) return '';
const tags: string[] = [];
for (const m of mutations) {
@@ -121,10 +96,10 @@ function parseMutationsToEntityTags(mutations: unknown[] | undefined): string {
// Hook
// ---------------------------------------------------------------------------
export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOptions): UseAIChatReturn {
export function useAIChat(defaultContext: UIChatContext): UseAIChatReturn {
const contextCacheKey = useMemo(
() => getContextCacheKey(defaultContext),
[defaultContext.type, defaultContext.projectId, defaultContext.scope?.type, defaultContext.scope?.id],
[defaultContext.type, defaultContext.projectId],
);
const [messages, setMessages] = useState<ChatMessage[]>(
@@ -148,9 +123,6 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
messagesRef.current = messages;
const sessionIdRef = useRef(sessionId);
sessionIdRef.current = sessionId;
const onDomainSignalRef = useRef(options?.onDomainSignal);
onDomainSignalRef.current = options?.onDomainSignal;
// Keep local state aligned when the chat context changes in-place.
useEffect(() => {
const cached = chatSessionCache.get(contextCacheKey);
@@ -234,9 +206,6 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
break;
}
case 'floating_domain':
onDomainSignalRef.current?.(event.domain);
break;
}
});
@@ -246,17 +215,12 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
content: m.content,
}));
const isFloating = ctx.type === 'floating';
chatMutationRef.current.mutate(
{
requestId,
message: trimmed,
conversationHistory,
sessionId: sessionIdRef.current,
...(isFloating && ctx.scope
? { mode: 'floating' as const, scope: ctx.scope }
: {}),
},
{
onSuccess: (data) => {

View File

@@ -0,0 +1,97 @@
import { useCallback, useRef, useState } from 'react';
import { trpc } from '@/lib/trpc';
import type { ChatMessage } from './useAIChat';
import { parseMutationsToEntityTags } from './useAIChat';
export type ChatStreamMode =
| { kind: 'home' }
| { kind: 'project'; projectId?: string }
| { kind: 'contextual'; scope: unknown };
export interface UseChatStreamArgs {
sessionId: string;
/** Called when the full assistant turn has been assembled. */
onAssistantMessage: (msg: ChatMessage) => void;
/** Called when the request fails. */
onError: (msg: ChatMessage) => void;
}
export function useChatStream({
sessionId,
onAssistantMessage,
onError,
}: UseChatStreamArgs) {
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const ref = useRef('');
const mutation = trpc.ai.chat.useMutation();
const mutationRef = useRef(mutation);
mutationRef.current = mutation;
const send = useCallback(
(args: {
message: string;
history: { role: 'user' | 'assistant'; content: string }[];
mode: ChatStreamMode;
}) => {
if (isStreaming) return;
setIsStreaming(true);
setStreamingContent('');
ref.current = '';
const requestId = crypto.randomUUID();
const unsubscribe = window.electronAI.onStreamEvent((event) => {
if (event.requestId !== requestId) return;
switch (event.type) {
case 'stream_start':
break;
case 'stream_text':
ref.current += event.chunk;
setStreamingContent(ref.current);
break;
case 'stream_end': {
const mutationTags = parseMutationsToEntityTags(event.mutations);
onAssistantMessage({
id: crypto.randomUUID(),
role: 'assistant',
content: ref.current + mutationTags,
});
setStreamingContent('');
ref.current = '';
setIsStreaming(false);
unsubscribe();
break;
}
}
});
const input: Record<string, unknown> = {
requestId,
message: args.message,
conversationHistory: args.history,
sessionId,
};
if (args.mode.kind === 'contextual') {
input.mode = 'contextual';
input.scope = args.mode.scope;
}
mutationRef.current.mutate(input as never, {
onError: (err) => {
unsubscribe();
onError({
id: crypto.randomUUID(),
role: 'assistant',
content: err.message || 'An unexpected error occurred.',
error: true,
});
setStreamingContent('');
ref.current = '';
setIsStreaming(false);
},
});
},
[sessionId, onAssistantMessage, onError, isStreaming],
);
return { send, isStreaming, streamingContent };
}

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