41 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 13:46:47 +02:00
69 changed files with 6170 additions and 2139 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).

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

@@ -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,7 +20,7 @@
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';
@@ -29,12 +29,10 @@ import {
} 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 +60,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,
@@ -181,7 +179,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;
@@ -292,7 +289,6 @@ export class BackendClient {
this.streamListeners.delete(activeRequestId);
resolve();
},
onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
onError: (err) => {
callbacks?.onError?.(err);
this.streamListeners.delete(activeRequestId);
@@ -352,7 +348,6 @@ export class BackendClient {
this.streamListeners.delete(activeRequestId);
resolve();
},
onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
onError: (err) => {
callbacks?.onError?.(err);
this.streamListeners.delete(activeRequestId);
@@ -391,72 +386,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.
@@ -477,7 +406,6 @@ export class BackendClient {
this.streamListeners.delete(activeRequestId);
resolve();
},
onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
onError: (err) => {
callbacks?.onError?.(err);
this.streamListeners.delete(activeRequestId);
@@ -641,6 +569,92 @@ export class BackendClient {
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
// -------------------------------------------------------------------------
@@ -887,14 +901,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);
});
@@ -970,20 +984,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);
}
@@ -1030,6 +1038,25 @@ export class BackendClient {
}
break;
}
case 'scout_proposal': {
const proposal = frame.data.proposal;
void (async () => {
try {
const { handleScoutProposal } = await import('../scouts/scout-suggestion-handler');
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;
}
}
});

View File

@@ -13,7 +13,7 @@
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, projectFolderFiles } from '../db/schema';
import type { WsToolCall } from '../../shared/api-types';
@@ -100,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]));
}
@@ -194,6 +208,8 @@ export class DrizzleExecutor {
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}"`);
}
@@ -552,6 +568,72 @@ export class DrizzleExecutor {
}
}
// -------------------------------------------------------------------------
// 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()

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

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

@@ -43,6 +43,27 @@
"when": 1778579196669,
"tag": "0005_slim_baron_strucker",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1778777130582,
"tag": "0006_misty_cammi",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1747353600000,
"tag": "0007_scouts_rename",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1747440000000,
"tag": "0008_scout_suggestions",
"breakpoints": true
}
]
}

View File

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

@@ -169,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' */
@@ -190,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

@@ -8,7 +8,7 @@ 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';
@@ -34,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 });
}
}
}
@@ -110,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.
@@ -134,7 +154,7 @@ 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);
});
@@ -142,7 +162,7 @@ app.on('ready', () => {
// 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

@@ -6,18 +6,19 @@ 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, taskAttachments, 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();
@@ -929,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,
});
}
@@ -1089,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
@@ -1106,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,
@@ -1116,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
@@ -1131,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 }),
@@ -1144,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);
@@ -1178,8 +1174,8 @@ const agentCloudRouter = router({
}))
.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 };
@@ -1202,8 +1198,8 @@ const agentCloudRouter = router({
.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 };
@@ -1217,16 +1213,32 @@ 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<{ authorize_url: string }>(
`/api/v1/scouts/oauth/gmail/authorize?scout_id=${encodeURIComponent(input.scoutId)}`,
);
await shell.openExternal(data.authorize_url);
return { ok: true };
}),
completeGmailOAuth: publicProcedure
.input(z.object({ code: z.string(), state: z.string() }))
.mutation(async ({ input }) => {
return await getBackendClient().proxyPost<{ ok: boolean }>('/api/v1/scouts/oauth/gmail/callback', input as Record<string, unknown>);
}),
});
const agentJourneyRouter = router({
const scoutJourneyRouter = router({
start: publicProcedure
.input(z.object({
agentType: z.enum(['local_directory', 'gmail', 'teams', 'outlook']),
@@ -1264,20 +1276,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
@@ -1293,18 +1305,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 };
@@ -1323,7 +1335,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 [];
}
}),
@@ -1336,60 +1348,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();
@@ -1397,12 +1409,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,
});
// ---------------------------------------------------------------------------
@@ -1853,9 +1865,10 @@ export const appRouter = router({
taskAttachments: taskAttachmentsRouter,
ai: aiRouter,
auth: authRouter,
agent: agentRouter,
scout: scoutRouter,
memory: memoryRouter,
projectFolders: projectFoldersRouter,
aiChat: aiChatRouter,
});
export type AppRouter = typeof appRouter;

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' | 'comment';
type ChatInputBoxVariant = 'panel' | 'comment';
interface ChatInputBoxProps {
cacheKey: string;
@@ -27,12 +27,6 @@ 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',
iconSize: 14,
},
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',
@@ -49,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

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

@@ -21,13 +21,13 @@ 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;
@@ -59,7 +59,6 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
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(
@@ -87,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).
@@ -158,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]));
@@ -467,9 +468,10 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
{subtitle}
</span>
</h1>
<div className="shrink-0 flex items-center gap-2">
<div
className={cn(
'shrink-0 overflow-hidden transition-all duration-200 ease-out',
'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',
)}
>
@@ -499,6 +501,7 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
}}
/>
</div>
</div>
</div>
</div>
</div>
@@ -518,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>
@@ -578,8 +580,6 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
onEdit={handleEditEvent}
onDuplicate={handleDuplicate}
onMove={handleMoveEvent}
sectionId="project-timeline"
sectionLabel="Project Timeline"
projectId={projectId}
/>
</div>
@@ -600,7 +600,6 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
<section
ref={tasksRef}
data-section="tasks"
data-ai-section="project-tasks"
className="flex flex-col gap-4"
>
<h1 className="text-2xl font-semibold tracking-tight">{t('projects.tasks')}</h1>
@@ -611,7 +610,6 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
<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">

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

@@ -9,7 +9,7 @@ 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,17 +79,18 @@ 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'),
@@ -89,7 +103,7 @@ export function ProjectTabBar({ sectionRefs, scrollRef, heroRef, initialTab }: P
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

@@ -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,5 +1,6 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { Button } from '@/components/ui/button';
@@ -12,23 +13,45 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { CloudAgentConfig } from '../../../../shared/api-types';
import type { CloudScoutConfig } from '../../../shared/api-types';
import { DATA_TYPES, SCHEDULE_OPTIONS } from './types';
export function CloudAgentConfigPanel({
agent,
export function CloudScoutConfigPanel({
scout,
onOpenJourney,
}: {
agent: CloudAgentConfig & { agentType: 'cloud' };
scout: CloudScoutConfig & { scoutType: 'cloud' };
onOpenJourney: () => void;
}) {
const { t } = useTranslation();
const utils = trpc.useUtils();
const updateMutation = trpc.agent.cloud.update.useMutation();
const updateMutation = trpc.scout.cloud.update.useMutation();
const startGmailOAuth = trpc.scout.cloud.startGmailOAuth.useMutation();
const completeGmailOAuth = trpc.scout.cloud.completeGmailOAuth.useMutation();
const [dataTypes, setDataTypes] = useState<string[]>(agent.dataTypes ?? []);
const [schedule, setSchedule] = useState(agent.scheduleCron ?? '0 * * * *');
const [dataTypes, setDataTypes] = useState<string[]>(scout.dataTypes ?? []);
const [schedule, setSchedule] = useState(scout.scheduleCron ?? '0 * * * *');
const { notify, notifyError } = useNotify();
// Subscribe to the Gmail OAuth deep-link callback forwarded by the main process.
useEffect(() => {
const electronAI = (window as unknown as { electronAI?: { onScoutGmailOAuthCallback?: (cb: (data: { code: string; state: string }) => void) => (() => void) } }).electronAI;
if (!electronAI?.onScoutGmailOAuthCallback) return;
const off = electronAI.onScoutGmailOAuthCallback(async ({ code, state }) => {
try {
await completeGmailOAuth.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 toggleDataType(type: string) {
setDataTypes(prev =>
prev.includes(type) ? prev.filter(t => t !== type) : [...prev, type],
@@ -37,25 +60,44 @@ export function CloudAgentConfigPanel({
function handleSave() {
updateMutation.mutate(
{ id: agent.id, dataTypes, scheduleCron: schedule },
{ id: scout.id, dataTypes, scheduleCron: schedule },
{
onSuccess: () => {
notify('success', 'toast.agent.updated');
void utils.agent.cloud.list.invalidate();
notify('success', 'toast.scout.updated');
void utils.scout.cloud.list.invalidate();
},
onError: (err) => notifyError('toast.agent.updateError', err),
onError: (err) => notifyError('toast.scout.updateError', err),
},
);
}
const showGmailConnect = scout.provider === 'gmail' && !scout.oauthConnected;
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>
<Badge variant="outline" className="capitalize">{scout.provider}</Badge>
<span className="text-xs text-muted-foreground">Connected service</span>
</div>
{/* Gmail OAuth connect button — shown when Gmail is not yet authorized */}
{showGmailConnect && (
<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">
Gmail access required to start receiving email suggestions.
</span>
<Button
size="sm"
variant="outline"
onClick={() => startGmailOAuth.mutate({ scoutId: scout.id })}
disabled={startGmailOAuth.isPending}
>
{t('scouts.connectGmail')}
</Button>
</div>
)}
{/* Data types */}
<div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-2">What to extract</label>

View File

@@ -17,12 +17,12 @@ import {
Dialog,
DialogContent,
} from '@/components/ui/dialog';
import type { AgentCatalogItem } from '../../../../shared/api-types';
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({
export function InlineScoutCreationStepper({
catalog,
isLoadingCatalog,
onCancel,
@@ -33,8 +33,8 @@ export function InlineAgentCreationStepper({
onCancel: () => void;
onCreated: () => void;
}) {
const createLocalMutation = trpc.agent.local.create.useMutation();
const createCloudMutation = trpc.agent.cloud.create.useMutation();
const createLocalMutation = trpc.scout.local.create.useMutation();
const createCloudMutation = trpc.scout.cloud.create.useMutation();
const { notify, notifyError } = useNotify();
const [step, setStep] = useState<1 | 2 | 3>(1);
@@ -45,7 +45,7 @@ export function InlineAgentCreationStepper({
const [dataTypes, setDataTypes] = useState<string[]>([]);
const [schedule, setSchedule] = useState('0 * * * *');
const [promptTemplate, setPromptTemplate] = useState('');
const [agentConfig, setAgentConfig] = useState<Record<string, unknown> | null>(null);
const [scoutConfig, setScoutConfig] = useState<Record<string, unknown> | null>(null);
const [error, setError] = useState('');
const isSubmitting = createLocalMutation.isPending || createCloudMutation.isPending;
@@ -57,7 +57,7 @@ export function InlineAgentCreationStepper({
setDataTypes((item.supportedDataTypes ?? []).slice(0, 2));
setSchedule('0 * * * *');
setPromptTemplate('');
setAgentConfig(null);
setScoutConfig(null);
setError('');
setStep(2);
}
@@ -66,7 +66,7 @@ export function InlineAgentCreationStepper({
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]!);
@@ -85,7 +85,7 @@ export function InlineAgentCreationStepper({
function nextFromConfig() {
if (!selectedTemplate) return;
if (!name.trim()) {
setError('Agent name is required.');
setError('Scout name is required.');
return;
}
if (selectedTemplate.type === 'local_directory' && !directory) {
@@ -112,15 +112,15 @@ export function InlineAgentCreationStepper({
directory,
dataTypes,
scheduleCron: schedule,
agentConfig: agentConfig ?? null,
agentConfig: scoutConfig ?? null,
},
{
onSuccess: () => {
notify('success', 'toast.agent.created');
notify('success', 'toast.scout.created');
onCreated();
},
onError: (err) => {
notifyError('toast.agent.createError', err);
notifyError('toast.scout.createError', err);
setError(err.message);
},
},
@@ -139,11 +139,11 @@ export function InlineAgentCreationStepper({
},
{
onSuccess: () => {
notify('success', 'toast.agent.created');
notify('success', 'toast.scout.created');
onCreated();
},
onError: (err) => {
notifyError('toast.agent.createError', err);
notifyError('toast.scout.createError', err);
setError(err.message);
},
},
@@ -161,7 +161,7 @@ export function InlineAgentCreationStepper({
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>
<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 && (
@@ -200,7 +200,7 @@ export function InlineAgentCreationStepper({
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."
placeholder="scout name."
spellCheck={false}
/>
</h2>
@@ -234,7 +234,7 @@ export function InlineAgentCreationStepper({
{/* 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.
After creating this scout, you'll be asked to sign in to <span className="font-medium text-foreground capitalize">{selectedTemplate.provider}</span> and grant read access.
</div>
)}
@@ -284,13 +284,13 @@ export function InlineAgentCreationStepper({
return (
<div>
<Button
variant={promptTemplate || agentConfig ? 'outline' : 'default'}
variant={promptTemplate || scoutConfig ? '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'}
{promptTemplate || scoutConfig ? 'Edit extraction prompt' : 'Build extraction prompt'}
</Button>
<Dialog open={promptDialogOpen} onOpenChange={setPromptDialogOpen}>
@@ -302,7 +302,7 @@ export function InlineAgentCreationStepper({
dataTypes={dataTypes}
directory={selectedTemplate.type === 'local_directory' ? directory : undefined}
onPromptUpdate={(p) => setPromptTemplate(p)}
onConfigUpdate={(c) => setAgentConfig(c)}
onConfigUpdate={(c) => setScoutConfig(c)}
/>
</div>
<div className="flex justify-end gap-2 px-5 py-4 border-t shrink-0">
@@ -327,9 +327,9 @@ export function InlineAgentCreationStepper({
<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>
<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 agent will start running on schedule.</p>
<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">
@@ -345,7 +345,7 @@ export function InlineAgentCreationStepper({
{selectedTemplate.type === 'local_directory' && directory && (
<p><span className="text-muted-foreground">Directory:</span> {directory}</p>
)}
{(selectedTemplate.type === 'local_directory' ? agentConfig : promptTemplate) && (
{(selectedTemplate.type === 'local_directory' ? scoutConfig : promptTemplate) && (
<p><span className="text-muted-foreground">Extraction config:</span> Added</p>
)}
</div>
@@ -380,7 +380,7 @@ export function InlineAgentCreationStepper({
{step === 3 && (
<Button size="sm" onClick={handleCreate} disabled={isSubmitting}>
Create agent now
Create scout now
</Button>
)}
</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

@@ -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,9 @@ 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 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,11 +38,11 @@ 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"
/>
@@ -54,7 +54,7 @@ export function AgentRow({
<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">
@@ -69,7 +69,7 @@ export function AgentRow({
</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 +82,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,165 @@
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 { 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() {
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,
});
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 }, {
onSuccess: () => {
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,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

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dialog,
@@ -368,6 +368,13 @@ export function TaskFormDialog({
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();
@@ -542,8 +549,8 @@ export function TaskFormDialog({
<DateTimeField
withTime
value={values.dueDate ? new Date(values.dueDate) : undefined}
onChange={(d) => setValues((v) => ({ ...v, dueDate: d ? d.getTime() : null }))}
onCommit={() => setDueOpen(false)}
onChange={handleDueChange}
onCommit={handleDueCommit}
aria-label={t('tasks.colDue')}
/>
</PopoverContent>

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

@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from 'react';
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';
@@ -89,6 +89,14 @@ export function DateTimeField({
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];
@@ -101,117 +109,149 @@ export function DateTimeField({
sel?.addRange(range);
}
function focusNext(curr: SegKey) {
const idx = order.indexOf(curr);
const next = order[idx + 1];
if (next) focusSeg(next);
}
function focusPrev(curr: SegKey) {
const idx = order.indexOf(curr);
const prev = order[idx - 1];
if (prev) focusSeg(prev);
}
// 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.
function applyState(next: SegState) {
setSeg(next);
const dt = toDate(next, withTime);
onChange(dt);
}
// 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;
function commit(state: SegState) {
const today = new Date();
const filled: SegState = {
day: state.day || String(today.getDate()).padStart(2, '0'),
month: state.month || String(today.getMonth() + 1).padStart(2, '0'),
year: state.year || String(today.getFullYear()),
hour: withTime ? (state.hour || '00') : '00',
minute: withTime ? (state.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);
const finalState = fromDate(dt);
setSeg(finalState);
onChange(dt);
onCommit?.(dt);
}
function onSegKeyDown(e: ReactKeyboardEvent<HTMLSpanElement>, key: SegKey) {
const onSegKeyDown = useCallback((e: ReactKeyboardEvent<HTMLSpanElement>, key: SegKey) => {
const def = SEGS[key];
const cur = seg[key];
if (e.key === 'ArrowRight') {
e.preventDefault();
focusNext(key);
const idx = orderRef.current.indexOf(key);
const nxt = orderRef.current[idx + 1];
if (nxt) focusSeg(nxt);
return;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
focusPrev(key);
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();
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;
const next = { ...seg, [key]: String(n).padStart(def.len, '0') };
applyState(next);
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();
if (cur === '') {
focusPrev(key);
} else {
const next = { ...seg, [key]: '' };
applyState(next);
}
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();
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');
const next = { ...seg, [key]: padded };
applyState(next);
if (padded.length >= def.len || parseInt(padded, 10) * 10 > def.max) {
focusNext(key);
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();
commit(seg);
// 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();
focusNext(key);
const idx = orderRef.current.indexOf(key);
const nxt = orderRef.current[idx + 1];
if (nxt) focusSeg(nxt);
return;
}
}
}, []);
function onCalendarSelect(d: Date | undefined) {
const onCalendarSelect = useCallback((d: Date | undefined) => {
if (!d) return;
const next: SegState = {
...seg,
day: String(d.getDate()).padStart(2, '0'),
month: String(d.getMonth() + 1).padStart(2, '0'),
year: String(d.getFullYear()),
};
applyState(next);
}
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']}>
@@ -225,7 +265,7 @@ export function DateTimeField({
segKey={sk}
value={seg[sk]}
onKeyDown={onSegKeyDown}
registerRef={(el) => { refs.current[sk] = el; }}
registerRef={refSetters.current[sk]}
sep={sep}
/>
))}
@@ -236,31 +276,25 @@ export function DateTimeField({
segKey="hour"
value={seg.hour}
onKeyDown={onSegKeyDown}
registerRef={(el) => { refs.current.hour = el; }}
registerRef={refSetters.current.hour}
sep=":"
/>
<SegmentSpan
segKey="minute"
value={seg.minute}
onKeyDown={onSegKeyDown}
registerRef={(el) => { refs.current.minute = el; }}
registerRef={refSetters.current.minute}
sep={null}
/>
</>
)}
</div>
<div className="rounded-md border">
<Calendar
mode="single"
selected={selectedDate}
onSelect={onCalendarSelect}
/>
</div>
<div className="rounded-md border">{calendarEl}</div>
</div>
);
}
function SegmentSpan({
const SegmentSpan = memo(function SegmentSpan({
segKey,
value,
onKeyDown,
@@ -305,4 +339,4 @@ function SegmentSpan({
{sep && <span className="text-muted-foreground/70 select-none px-0.5">{sep}</span>}
</>
);
}
});

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

View File

@@ -0,0 +1,12 @@
import { useEffect } from 'react';
import { useContextualChat, type ContextualScope } from '@/context/ContextualChatContext';
export function useContextualScope(scope: ContextualScope) {
const { setScope } = useContextualChat();
const key = JSON.stringify(scope);
useEffect(() => {
setScope(scope);
// setScope is a stable identity via the provider's useCallback; safe to omit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
}

View File

@@ -1,54 +0,0 @@
import { useEffect } from 'react';
import { useFloatingChat } from '@/context/FloatingChatContext';
// Elements where double-click should NOT trigger the AI popup
const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
export function useDoubleClickAI(): void {
const { openAtSection, moveToSection, sections, state } = useFloatingChat();
useEffect(() => {
const handler = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// Skip interactive elements (preserve text selection behavior)
if (INTERACTIVE_TAGS.has(target.tagName)) return;
// Skip contenteditable elements UNLESS they're inside Milkdown
if (target.isContentEditable) {
const inMilkdown =
target.closest('.milkdown-container') ||
target.closest('.crepe-editor');
if (!inMilkdown) return;
// For Milkdown: only trigger if no text was selected by the double-click
const selection = window.getSelection();
if (selection && selection.toString().trim().length > 0) return;
}
// Walk up DOM to find nearest [data-ai-section]
const sectionEl = (target as Element).closest('[data-ai-section]');
if (!sectionEl) return;
const sectionId = sectionEl.getAttribute('data-ai-section');
if (!sectionId) return;
// If popup is already open at THIS section, do nothing
if (state.isOpen && state.activeSectionId === sectionId) return;
// Build opts for right-margin sections
const section = sections.get(sectionId);
const opts = section?.anchorMode === 'right-margin' ? { clickY: e.clientY } : undefined;
// If chat is already open at a different section, move (keep conversation)
if (state.isOpen) {
moveToSection(sectionId, opts);
return;
}
openAtSection(sectionId, opts);
};
document.addEventListener('dblclick', handler);
return () => document.removeEventListener('dblclick', handler);
}, [openAtSection, moveToSection, sections, state.isOpen, state.activeSectionId]);
}

View File

@@ -0,0 +1,27 @@
import { useEffect, type ReactNode } from 'react';
import { useHeader } from '@/context/HeaderContext';
/**
* Publish a dynamic label and/or extra actions into the AppShell header.
* Clears them on unmount so the default page label takes over again.
*/
export function useHeaderSlot({
label,
extras,
}: {
label?: string | null;
extras?: ReactNode;
}) {
const { setLabel, setExtras } = useHeader();
useEffect(() => {
if (label !== undefined) setLabel(label ?? null);
return () => setLabel(null);
}, [label, setLabel]);
useEffect(() => {
if (extras !== undefined) setExtras(extras ?? null);
return () => setExtras(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [extras, setExtras]);
}

View File

@@ -16,25 +16,13 @@ interface ElectronTRPC {
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[] };
interface ElectronAI {
onStreamEvent: (cb: (data: V3StreamEvent) => void) => () => void;
onBriefUpdated: (cb: (content: string) => void) => () => void;
/** Exposed by preload in M4.7. Best-effort fire when renderer scope changes. */
sendContextualScopeUpdate?: (args: { sessionId: string; scope: unknown }) => void;
}
interface ElectronDialog {

View File

@@ -99,9 +99,9 @@
"account": "Konto",
"accountSubtitle": "Sicherheit und Zugang.",
"accountDescription": "Verwalten Sie Sicherheit, Verbindungen und Kontoeinstellungen.",
"agents": "Agenten",
"agentsSubtitle": "arbeiten für Sie.",
"agentsDescription": "Unter-Agenten, die für Sie arbeiten — Daten aus lokalen Dateien oder Cloud-Diensten sammeln, wiederkehrende Aktionen auslösen und Ihren Arbeitsbereich synchron halten.",
"scouts": "Scouts",
"scoutsSubtitle": "die für dich arbeiten.",
"scoutsDescription": "Scouts überwachen deine Datenquellen — lokale Dateien, Postfächer, Cloud-Dienste — und heben hervor, was im Task Brief zählt.",
"aiPreferences": "KI-Einstellungen",
"aiPreferencesSubtitle": "auf Sie zugeschnitten.",
"aiPreferencesDescription": "Personalisieren Sie, wie die KI auf Sie reagiert.",
@@ -418,15 +418,16 @@
"webOnlyTooltip": "Ordnerverknüpfung ist in der Desktop-App verfügbar"
}
},
"agents": {
"title": "Agenten",
"subtitle": "arbeiten für Sie.",
"description": "Unteragenten, die in Ihrem Auftrag arbeiten — Daten aus lokalen Dateien oder Cloud-Diensten sammeln, wiederkehrende Aktionen auslösen und Ihren Arbeitsbereich synchron halten.",
"noAgentsYet": "Noch keine Agenten",
"noAgentsDescription": "Erstellen Sie Ihren ersten Agenten aus einer Vorlage. Sie können wählen, welche Daten extrahiert werden, einen Zeitplan festlegen und Anweisungen bearbeiten.",
"createFirstAgent": "Ersten Agenten erstellen",
"yourAgents": "Ihre Agenten",
"createAgent": "Agent erstellen"
"scouts": {
"title": "Scouts",
"subtitle": "die für dich arbeiten.",
"description": "Scouts überwachen deine Datenquellen — lokale Dateien, Postfächer, Cloud-Dienste — und heben hervor, was im Task Brief zählt.",
"noScoutsYet": "Noch keine Scouts",
"noScoutsDescription": "Erstelle deinen ersten Scout aus einer Vorlage. Wähle, welche Daten extrahiert werden, lege einen Zeitplan fest und bearbeite die Anweisungen vor dem Speichern.",
"createFirstScout": "Ersten Scout erstellen",
"yourScouts": "Deine Scouts",
"createScout": "Scout erstellen",
"connectGmail": "Gmail verbinden"
},
"toast": {
"profile": {
@@ -513,15 +514,16 @@
"createError": "Datei konnte nicht angehängt werden.",
"tooLarge": "{{filename}} ist zu groß (Limit 50 MB)."
},
"agent": {
"created": "Agent erstellt",
"createError": "Agent konnte nicht erstellt werden",
"updated": "Agent-Konfiguration gespeichert",
"scout": {
"created": "Scout erstellt",
"createError": "Scout konnte nicht erstellt werden",
"updated": "Scout-Konfiguration gespeichert",
"updateError": "Konfiguration konnte nicht gespeichert werden",
"deleted": "Agent gelöscht",
"deleteError": "Agent konnte nicht gelöscht werden",
"runStarted": "Agent-Ausführung gestartet",
"runError": "Agent konnte nicht gestartet werden"
"deleted": "Scout gelöscht",
"deleteError": "Scout konnte nicht gelöscht werden",
"runStarted": "Scout-Ausführung gestartet",
"runError": "Scout konnte nicht gestartet werden",
"gmailConnected": "Gmail verbunden. Wartet auf neue Nachrichten."
}
},
"date": {

View File

@@ -99,9 +99,9 @@
"account": "Account",
"accountSubtitle": "security & access.",
"accountDescription": "Manage your security, connections, and account settings.",
"agents": "Agents",
"agentsSubtitle": "working for you.",
"agentsDescription": "Sub-agents that work on your behalf — collecting data from local files or cloud services, triggering recurring actions, and keeping your workspace in sync.",
"scouts": "Scouts",
"scoutsSubtitle": "working for you.",
"scoutsDescription": "Scouts watch your data sources — local files, mailboxes, cloud services — and surface what matters in your task brief.",
"aiPreferences": "AI Preferences",
"aiPreferencesSubtitle": "tailored to you.",
"aiPreferencesDescription": "Personalize how the AI responds to you.",
@@ -418,15 +418,16 @@
"webOnlyTooltip": "Folder linking available in desktop app"
}
},
"agents": {
"title": "Agents",
"scouts": {
"title": "Scouts",
"subtitle": "working for you.",
"description": "Sub-agents that work on your behalf — collecting data from local files or cloud services, triggering recurring actions, and keeping your workspace in sync.",
"noAgentsYet": "No agents yet",
"noAgentsDescription": "Create your first agent from a template. You can choose what data to extract, set a schedule, and edit instructions before saving.",
"createFirstAgent": "Create first agent",
"yourAgents": "Your Agents",
"createAgent": "Create agent"
"description": "Scouts watch your data sources — local files, mailboxes, cloud services — and surface what matters in your task brief.",
"noScoutsYet": "No scouts yet",
"noScoutsDescription": "Create your first scout from a template. Choose what data to extract, set a schedule, and edit instructions before saving.",
"createFirstScout": "Create first scout",
"yourScouts": "Your Scouts",
"createScout": "Create scout",
"connectGmail": "Connect Gmail"
},
"toast": {
"profile": {
@@ -513,15 +514,16 @@
"createError": "Could not attach file.",
"tooLarge": "{{filename}} is too large (limit 50 MB)."
},
"agent": {
"created": "Agent created",
"createError": "Failed to create agent",
"updated": "Agent configuration saved",
"updateError": "Failed to save agent configuration",
"deleted": "Agent deleted",
"deleteError": "Failed to delete agent",
"runStarted": "Agent run started",
"runError": "Failed to start agent"
"scout": {
"created": "Scout created",
"createError": "Failed to create scout",
"updated": "Scout configuration saved",
"updateError": "Failed to save scout configuration",
"deleted": "Scout deleted",
"deleteError": "Failed to delete scout",
"runStarted": "Scout run started",
"runError": "Failed to start scout",
"gmailConnected": "Gmail connected. Watching for new messages."
}
},
"date": {

View File

@@ -99,9 +99,9 @@
"account": "Cuenta",
"accountSubtitle": "seguridad y acceso.",
"accountDescription": "Gestiona tu seguridad, conexiones y configuración de cuenta.",
"agents": "Agentes",
"agentsSubtitle": "trabajando para ti.",
"agentsDescription": "Sub-agentes que trabajan en tu nombre — recopilando datos de archivos locales o servicios en la nube, activando acciones recurrentes y manteniendo tu espacio de trabajo sincronizado.",
"scouts": "Scouts",
"scoutsSubtitle": "trabajando para ti.",
"scoutsDescription": "Los scouts vigilan tus fuentes de datos archivos locales, buzones, servicios en la nube — y destacan lo que importa en tu brief de tareas.",
"aiPreferences": "Preferencias de IA",
"aiPreferencesSubtitle": "adaptado a ti.",
"aiPreferencesDescription": "Personaliza cómo la IA responde a tus solicitudes.",
@@ -418,15 +418,16 @@
"webOnlyTooltip": "La vinculación de carpetas está disponible en la app de escritorio"
}
},
"agents": {
"title": "Agentes",
"scouts": {
"title": "Scouts",
"subtitle": "trabajando para ti.",
"description": "Sub-agentes que trabajan en tu nombre — recopilando datos de archivos locales o servicios en la nube, activando acciones recurrentes y manteniendo tu espacio de trabajo sincronizado.",
"noAgentsYet": "No hay agentes",
"noAgentsDescription": "Crea tu primer agente desde una plantilla. Puedes elegir qué datos extraer, establecer un horario y editar las instrucciones antes de guardar.",
"createFirstAgent": "Crear primer agente",
"yourAgents": "Tus agentes",
"createAgent": "Crear agente"
"description": "Los scouts vigilan tus fuentes de datos archivos locales, buzones, servicios en la nube — y destacan lo que importa en tu brief de tareas.",
"noScoutsYet": "No hay scouts",
"noScoutsDescription": "Crea tu primer scout desde una plantilla. Elige qué datos extraer, establece un horario y edita las instrucciones antes de guardar.",
"createFirstScout": "Crear primer scout",
"yourScouts": "Tus scouts",
"createScout": "Crear scout",
"connectGmail": "Conectar Gmail"
},
"toast": {
"profile": {
@@ -513,15 +514,16 @@
"createError": "No se pudo adjuntar el archivo.",
"tooLarge": "{{filename}} es demasiado grande (límite 50 MB)."
},
"agent": {
"created": "Agente creado",
"createError": "Error al crear el agente",
"updated": "Configuración del agente guardada",
"scout": {
"created": "Scout creado",
"createError": "Error al crear el scout",
"updated": "Configuración del scout guardada",
"updateError": "Error al guardar la configuración",
"deleted": "Agente eliminado",
"deleteError": "Error al eliminar el agente",
"runStarted": "Ejecución del agente iniciada",
"runError": "Error al iniciar el agente"
"deleted": "Scout eliminado",
"deleteError": "Error al eliminar el scout",
"runStarted": "Ejecución del scout iniciada",
"runError": "Error al iniciar el scout",
"gmailConnected": "Gmail conectado. Atento a nuevos mensajes."
}
},
"date": {

View File

@@ -99,9 +99,9 @@
"account": "Compte",
"accountSubtitle": "sécurité et accès.",
"accountDescription": "Gérez votre sécurité, connexions et paramètres de compte.",
"agents": "Agents",
"agentsSubtitle": "à votre service.",
"agentsDescription": "Sous-agents qui travaillent pour vous — collectant des données depuis des fichiers locaux ou services cloud, déclenchant des actions récurrentes et maintenant votre espace de travail synchronisé.",
"scouts": "Scouts",
"scoutsSubtitle": "qui travaillent pour vous.",
"scoutsDescription": "Les scouts surveillent vos sources de données fichiers locaux, boîtes mail, services cloud — et font remonter ce qui compte dans votre brief de tâches.",
"aiPreferences": "Préférences IA",
"aiPreferencesSubtitle": "adapté à vous.",
"aiPreferencesDescription": "Personnalisez la façon dont l'IA vous répond.",
@@ -418,15 +418,16 @@
"webOnlyTooltip": "La liaison de dossiers est disponible dans l'application bureau"
}
},
"agents": {
"title": "Agents",
"subtitle": "à votre service.",
"description": "Sous-agents qui travaillent pour vous — collectant des données depuis des fichiers locaux ou des services cloud, déclenchant des actions récurrentes et gardant votre espace de travail synchronisé.",
"noAgentsYet": "Aucun agent",
"noAgentsDescription": "Créez votre premier agent à partir d'un modèle. Vous pouvez choisir les données à extraire, définir un planning et modifier les instructions avant d'enregistrer.",
"createFirstAgent": "Créer le premier agent",
"yourAgents": "Vos agents",
"createAgent": "Créer un agent"
"scouts": {
"title": "Scouts",
"subtitle": "qui travaillent pour vous.",
"description": "Les scouts surveillent vos sources de données fichiers locaux, boîtes mail, services cloud — et font remonter ce qui compte dans votre brief de tâches.",
"noScoutsYet": "Aucun scout",
"noScoutsDescription": "Créez votre premier scout à partir d'un modèle. Choisissez les données à extraire, définissez un planning et modifiez les instructions avant d'enregistrer.",
"createFirstScout": "Créer le premier scout",
"yourScouts": "Vos scouts",
"createScout": "Créer un scout",
"connectGmail": "Connecter Gmail"
},
"toast": {
"profile": {
@@ -513,15 +514,16 @@
"createError": "Impossible de joindre le fichier.",
"tooLarge": "{{filename}} est trop volumineux (limite 50 Mo)."
},
"agent": {
"created": "Agent créé",
"createError": "Impossible de créer l'agent",
"updated": "Configuration de l'agent enregistrée",
"scout": {
"created": "Scout créé",
"createError": "Impossible de créer le scout",
"updated": "Configuration du scout enregistrée",
"updateError": "Impossible d'enregistrer la configuration",
"deleted": "Agent supprimé",
"deleteError": "Impossible de supprimer l'agent",
"runStarted": "Exécution de l'agent lancée",
"runError": "Impossible de lancer l'agent"
"deleted": "Scout supprimé",
"deleteError": "Impossible de supprimer le scout",
"runStarted": "Exécution du scout lancée",
"runError": "Impossible de lancer le scout",
"gmailConnected": "Gmail connecté. À l'écoute des nouveaux messages."
}
},
"date": {

View File

@@ -99,9 +99,9 @@
"account": "Account",
"accountSubtitle": "sicurezza e accesso.",
"accountDescription": "Gestisci sicurezza, connessioni e impostazioni dell'account.",
"agents": "Agenti",
"agentsSubtitle": "al tuo servizio.",
"agentsDescription": "Sotto-agenti che lavorano per te — raccogliendo dati da file locali o servizi cloud, attivando azioni ricorrenti e mantenendo il tuo workspace sincronizzato.",
"scouts": "Scout",
"scoutsSubtitle": "che lavorano per te.",
"scoutsDescription": "Gli scout monitorano le tue fonti dati file locali, caselle email, servizi cloud — e mettono in evidenza ciò che conta nel tuo task brief.",
"aiPreferences": "Preferenze AI",
"aiPreferencesSubtitle": "su misura per te.",
"aiPreferencesDescription": "Personalizza come l'AI risponde alle tue richieste.",
@@ -418,15 +418,16 @@
"webOnlyTooltip": "Il collegamento cartelle è disponibile nell'app desktop"
}
},
"agents": {
"title": "Agenti",
"subtitle": "al tuo servizio.",
"description": "Sotto-agenti che lavorano per te — raccogliendo dati da file locali o servizi cloud, attivando azioni ricorrenti e mantenendo il tuo workspace sincronizzato.",
"noAgentsYet": "Nessun agente",
"noAgentsDescription": "Crea il tuo primo agente da un template. Puoi scegliere quali dati estrarre, impostare una pianificazione e modificare le istruzioni prima di salvare.",
"createFirstAgent": "Crea primo agente",
"yourAgents": "I tuoi agenti",
"createAgent": "Crea agente"
"scouts": {
"title": "Scout",
"subtitle": "che lavorano per te.",
"description": "Gli scout monitorano le tue fonti dati file locali, caselle email, servizi cloud — e mettono in evidenza ciò che conta nel tuo task brief.",
"noScoutsYet": "Nessuno scout",
"noScoutsDescription": "Crea il tuo primo scout da un template. Scegli quali dati estrarre, imposta una pianificazione e modifica le istruzioni prima di salvare.",
"createFirstScout": "Crea primo scout",
"yourScouts": "I tuoi scout",
"createScout": "Crea scout",
"connectGmail": "Connetti Gmail"
},
"toast": {
"profile": {
@@ -513,15 +514,16 @@
"createError": "Impossibile allegare il file.",
"tooLarge": "{{filename}} è troppo grande (limite 50 MB)."
},
"agent": {
"created": "Agente creato",
"createError": "Impossibile creare l'agente",
"updated": "Configurazione agente salvata",
"scout": {
"created": "Scout creato",
"createError": "Impossibile creare lo scout",
"updated": "Configurazione scout salvata",
"updateError": "Impossibile salvare la configurazione",
"deleted": "Agente eliminato",
"deleteError": "Impossibile eliminare l'agente",
"runStarted": "Esecuzione agente avviata",
"runError": "Impossibile avviare l'agente"
"deleted": "Scout eliminato",
"deleteError": "Impossibile eliminare lo scout",
"runStarted": "Esecuzione scout avviata",
"runError": "Impossibile avviare lo scout",
"gmailConnected": "Gmail connesso. In ascolto di nuovi messaggi."
}
},
"date": {

View File

@@ -1,9 +1,9 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { ArrowLeft, Trash2, MoreHorizontal, Sparkles } from 'lucide-react';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { Separator } from '@/components/ui/separator';
import { useContextualScope } from '@/hooks/useContextualScope';
import { useFormatPrefs, formatDateTime } from '@/lib/date';
import { useHeader } from '@/context/HeaderContext';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
@@ -26,12 +26,75 @@ import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { MilkdownEditor } from '@/components/notes/MilkdownEditor';
import { PendingEditBlock } from '@/components/notes/PendingEditBlock';
import { useFloatingChat } from '@/context/FloatingChatContext';
export const Route = createFileRoute('/notes/$noteId')({
component: NoteDetailPage,
});
// ---------------------------------------------------------------------------
// Stable header slot components — defined at module scope so React never
// remounts them when NoteDetailPage re-renders. They read mutable refs for
// live values (isSaving, callbacks) to avoid triggering HeaderContext updates.
// ---------------------------------------------------------------------------
function NoteHeaderLeft({
handleBackRef,
}: {
handleBackRef: React.MutableRefObject<() => void>;
}) {
return (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleBackRef.current()}
title="Back"
>
<ArrowLeft className="h-4 w-4" />
</Button>
);
}
function NoteHeaderRight({
isSavingRef,
setDeleteOpenRef,
}: {
isSavingRef: React.MutableRefObject<boolean>;
setDeleteOpenRef: React.MutableRefObject<(open: boolean) => void>;
}) {
const [saving, setSaving] = useState(isSavingRef.current);
useEffect(() => {
const id = setInterval(() => {
setSaving(isSavingRef.current);
}, 150);
return () => clearInterval(id);
}, [isSavingRef]);
return (
<div className="flex items-center gap-2">
{saving && (
<span className="text-muted-foreground text-xs">Saving</span>
)}
<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
className="text-destructive focus:text-destructive"
onClick={() => setDeleteOpenRef.current(true)}
>
<Trash2 className="h-4 w-4" />
Delete note
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
function NoteDetailPage() {
const { noteId } = Route.useParams();
const navigate = useNavigate();
@@ -40,19 +103,16 @@ function NoteDetailPage() {
const { data: note, isLoading } = trpc.notes.get.useQuery({ id: noteId });
const { data: pendingEdits = [] } = trpc.noteEdits.listPending.useQuery({ noteId });
useContextualScope({
page: 'note',
entityType: note ? 'note' : null,
entityId: note?.id,
entityName: note?.title,
projectId: note?.projectId ?? null,
charCount: (note?.content ?? '').length,
});
const editorRef = useRef<HTMLDivElement>(null);
const { registerSection, unregisterSection } = useFloatingChat();
const noteProjectId = note?.projectId ?? undefined;
useEffect(() => {
registerSection({
id: 'note-editor',
label: 'Note Editor',
ref: editorRef,
projectId: noteProjectId,
anchorMode: 'right-margin',
});
return () => unregisterSection('note-editor');
}, [noteId, noteProjectId, registerSection, unregisterSection]);
const [title, setTitle] = useState('');
const [isSaving, setIsSaving] = useState(false);
@@ -147,7 +207,7 @@ function NoteDetailPage() {
});
}, [navigate, note?.projectId]);
const handleBack = () => {
const handleBack = useCallback(() => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
@@ -157,7 +217,35 @@ function NoteDetailPage() {
pendingContentRef.current = null;
}
goBackToProject();
};
}, [updateNote, noteId, goBackToProject]);
const { setLeftExtras, setRightExtras } = useHeader();
// Keep mutable refs so the stable header components can read the latest
// values without causing a re-publish of the header slots on each render.
const isSavingRef = useRef(isSaving);
isSavingRef.current = isSaving;
const setDeleteOpenRef = useRef(setDeleteOpen);
setDeleteOpenRef.current = setDeleteOpen;
const handleBackRef = useRef(handleBack);
handleBackRef.current = handleBack;
useEffect(() => {
setLeftExtras(
<NoteHeaderLeft handleBackRef={handleBackRef} />,
);
setRightExtras(
<NoteHeaderRight isSavingRef={isSavingRef} setDeleteOpenRef={setDeleteOpenRef} />,
);
return () => {
setLeftExtras(null);
setRightExtras(null);
};
// setLeftExtras / setRightExtras are stable; refs are always current.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setLeftExtras, setRightExtras]);
const handleDelete = () => {
if (debounceTimerRef.current) {
@@ -186,38 +274,8 @@ function NoteDetailPage() {
return (
<div className="flex h-full min-h-0 flex-col">
{/* Minimal top bar */}
<div className="flex h-14 shrink-0 items-center gap-1 px-3">
<SidebarTrigger />
<Separator orientation="vertical" className="data-[orientation=vertical]:h-4" />
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleBack}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="ml-auto flex items-center gap-2">
{isSaving && (
<span className="text-muted-foreground text-xs">Saving</span>
)}
<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
className="text-destructive focus:text-destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="h-4 w-4" />
Delete note
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Notion-style content area */}
<ScrollArea ref={editorRef} data-ai-section="note-editor" className="flex-1 min-h-0">
<ScrollArea ref={editorRef} className="flex-1 min-h-0">
<div className="mx-auto max-w-3xl pb-32 pt-14">
{/* Title styled to match the project page h1 */}
<input

View File

@@ -1,10 +1,14 @@
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
import { FolderKanban } from 'lucide-react';
import { FolderKanban, Plus } from 'lucide-react';
import { useState, useMemo } from 'react';
import { ProjectSidebar } from '@/components/projects/ProjectSidebar';
import { ProjectDetail } from '@/components/projects/ProjectDetail';
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
import { Button } from '@/components/ui/button';
import { useTranslation } from 'react-i18next';
import { useContextualScope } from '@/hooks/useContextualScope';
import { useHeaderSlot } from '@/hooks/useHeaderSlot';
const searchSchema = z.object({
projectId: z.string().optional(),
@@ -16,11 +20,45 @@ export const Route = createFileRoute('/projects')({
component: ProjectsPage,
});
// Rendered only when no project is selected. Owns the 'projects-list'
// scope registration so it unmounts when ProjectDetail takes over,
// preventing the parent-effect-fires-last problem where ProjectsPage
// would clobber ProjectDetail's 'project' scope.
function ProjectsListScope() {
useContextualScope({ page: 'projects-list' });
return null;
}
function ProjectsPage() {
const { t } = useTranslation();
const { projectId, tab } = Route.useSearch();
const navigate = Route.useNavigate();
// Create-project dialog open state — lifted here so the header button can trigger it
const [newProjectOpen, setNewProjectOpen] = useState(false);
// Push the create-project icon button into the AppShell header (between the page
// label and the AI trigger). Always shown on the projects route.
// Memoized to keep a stable JSX reference so the useHeaderSlot effect fires once.
const createProjectButton = useMemo(
() => (
<Button
size="icon"
variant="outline"
className="size-7"
onClick={() => setNewProjectOpen(true)}
aria-label={t('projects.newProject')}
>
<Plus size={14} />
</Button>
),
// t is stable; setNewProjectOpen is stable (React guarantees setState is stable).
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
useHeaderSlot({ extras: createProjectButton });
function handleSelectProject(id: string) {
void navigate({ search: { projectId: id } });
}
@@ -30,22 +68,27 @@ function ProjectsPage() {
<ProjectSidebar
selectedProjectId={projectId}
onSelectProject={handleSelectProject}
newProjectOpen={newProjectOpen}
setNewProjectOpen={setNewProjectOpen}
/>
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
{projectId ? (
<ProjectDetail projectId={projectId} initialTab={tab} />
) : (
<Empty className="h-full">
<EmptyHeader>
<EmptyMedia variant="icon">
<FolderKanban />
</EmptyMedia>
<EmptyTitle>{t('projects.noProjectSelected')}</EmptyTitle>
<EmptyDescription>
{t('projects.noProjectSelectedDescription')}
</EmptyDescription>
</EmptyHeader>
</Empty>
<>
<ProjectsListScope />
<Empty className="flex-1">
<EmptyHeader>
<EmptyMedia variant="icon">
<FolderKanban />
</EmptyMedia>
<EmptyTitle>{t('projects.noProjectSelected')}</EmptyTitle>
<EmptyDescription>
{t('projects.noProjectSelectedDescription')}
</EmptyDescription>
</EmptyHeader>
</Empty>
</>
)}
</div>
</div>

View File

@@ -7,7 +7,7 @@ import { SidebarTrigger } from '@/components/ui/sidebar';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { AccountSection } from '@/components/settings/AccountSection';
import { AgentsSection } from '@/components/settings/AgentsSection';
import { ScoutsSection } from '@/components/settings/ScoutsSection';
import { AppearanceSection } from '@/components/settings/AppearanceSection';
import { BillingSection } from '@/components/settings/BillingSection';
import { MemorySection } from '@/components/settings/MemorySection';
@@ -69,8 +69,8 @@ function SettingsPage() {
: t(`settings.${section}Subtitle`)}
</span>
</h1>
{section === 'agents' && (
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">{t('settings.agentsDescription')}</p>
{section === 'scouts' && (
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">{t('settings.scoutsDescription')}</p>
)}
</div>
{section === 'profile' && <ProfileSection />}
@@ -78,7 +78,7 @@ function SettingsPage() {
{section === 'account' && <AccountSection />}
{section === 'billing' && <BillingSection />}
{section === 'appearance' && <AppearanceSection />}
{section === 'agents' && <AgentsSection />}
{section === 'scouts' && <ScoutsSection />}
</div>
</ScrollArea>
</div>

View File

@@ -1,8 +1,8 @@
import { createFileRoute } from '@tanstack/react-router';
import { useEffect, useMemo, useRef } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ClipboardCheck, ListTodo, Clock, CheckCircle2 } from 'lucide-react';
import { useFloatingChat } from '@/context/FloatingChatContext';
import { useContextualScope } from '@/hooks/useContextualScope';
import { trpc } from '@/lib/trpc';
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
import { TaskListView } from '@/components/tasks/TaskListView';
@@ -11,18 +11,7 @@ export const Route = createFileRoute('/tasks')({ component: TasksPage });
function TasksPage() {
const { t } = useTranslation();
const overviewRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const { registerSection, unregisterSection } = useFloatingChat();
useEffect(() => {
registerSection({ id: 'tasks-overview', label: 'Tasks Overview', ref: overviewRef });
registerSection({ id: 'tasks-list', label: 'Task List', ref: listRef });
return () => {
unregisterSection('tasks-overview');
unregisterSection('tasks-list');
};
}, [registerSection, unregisterSection]);
useContextualScope({ page: 'tasks' });
const { data: allTasks } = trpc.tasks.list.useQuery({});
const stats = useMemo(() => {
@@ -36,8 +25,8 @@ function TasksPage() {
}, [allTasks]);
return (
<div className="flex flex-col gap-6 p-6 pt-0 w-full">
<div ref={overviewRef} data-ai-section="tasks-overview" className="grid grid-cols-4 gap-4">
<div className="flex flex-col gap-6 p-6 pt-0 w-full h-full overflow-y-auto">
<div className="grid grid-cols-4 gap-4">
<Item variant="muted">
<ItemMedia variant="icon"><ClipboardCheck /></ItemMedia>
<ItemContent><ItemTitle>{stats.total}</ItemTitle><ItemDescription>{t('tasks.totalTasks')}</ItemDescription></ItemContent>
@@ -56,7 +45,7 @@ function TasksPage() {
</Item>
</div>
<div ref={listRef} data-ai-section="tasks-list">
<div>
<TaskListView />
</div>
</div>

View File

@@ -2,6 +2,7 @@ import { createFileRoute } from '@tanstack/react-router';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Archive, ArchiveX } from 'lucide-react';
import { useContextualScope } from '@/hooks/useContextualScope';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { Switch } from '@/components/ui/switch';
@@ -22,6 +23,7 @@ export const Route = createFileRoute('/timeline')({
function TimelinePage() {
const { t } = useTranslation();
useContextualScope({ page: 'timeline' });
const [dialogOpen, setDialogOpen] = useState(false);
const [editingEvent, setEditingEvent] = useState<TimelineEvent | null>(null);
const [showArchived, setShowArchived] = useState(false);
@@ -231,27 +233,31 @@ function TimelinePage() {
onAdd={() => setDialogOpen(true)}
renderHeaderExtras={(compact) =>
compact ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => setShowArchived((v) => !v)}
>
{showArchived
? <ArchiveX className="size-3.5" />
: <Archive className="size-3.5" />}
</Button>
</TooltipTrigger>
<TooltipContent>{t('timeline.showArchived')}</TooltipContent>
</Tooltip>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => setShowArchived((v) => !v)}
>
{showArchived
? <ArchiveX className="size-3.5" />
: <Archive className="size-3.5" />}
</Button>
</TooltipTrigger>
<TooltipContent>{t('timeline.showArchived')}</TooltipContent>
</Tooltip>
</div>
) : (
<div className="flex items-center gap-1.5">
<Label htmlFor="show-archived" className="text-xs text-muted-foreground">
{t('timeline.showArchived')}
</Label>
<Switch id="show-archived" checked={showArchived} onCheckedChange={setShowArchived} />
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<Label htmlFor="show-archived" className="text-xs text-muted-foreground">
{t('timeline.showArchived')}
</Label>
<Switch id="show-archived" checked={showArchived} onCheckedChange={setShowArchived} />
</div>
</div>
)
}
@@ -287,8 +293,6 @@ function TimelinePage() {
onEdit={(ev) => setEditingEvent(ev)}
onDuplicate={handleDuplicate}
onMove={handleMove}
sectionId="timeline-chart"
sectionLabel="Timeline"
/>
<AddEventDialog open={dialogOpen} onOpenChange={setDialogOpen} onRecordHistory={record} />
<EditEventDialog

View File

@@ -55,6 +55,7 @@ export const ToolCallActionSchema = z.enum([
'read_project_folder_manifest',
'read_project_folder_file',
'list_projects_with_folder_manifests',
'get_page_details',
]);
export type ToolCallAction = z.infer<typeof ToolCallActionSchema>;
@@ -76,12 +77,12 @@ export type WsToolResult = z.infer<typeof WsToolResultSchema>;
/**
* First frame sent by Electron on the persistent device WS connection.
* Identifies the device and the agent configs it owns.
* Identifies the device and the scout configs it owns.
*/
export const WsDeviceHelloSchema = z.object({
type: z.literal('device_hello'),
deviceId: z.string(),
agentIds: z.array(z.string()),
scoutIds: z.array(z.string()),
});
export type WsDeviceHello = z.infer<typeof WsDeviceHelloSchema>;
@@ -96,19 +97,6 @@ export const WsHomeRequestSchema = z.object({
});
export type WsHomeRequest = z.infer<typeof WsHomeRequestSchema>;
export const WsFloatingRequestSchema = z.object({
type: z.literal('floating_request'),
message: z.string(),
scope: z.object({
type: z.enum(['task', 'project', 'note', 'timeline']),
id: z.string().optional(),
}),
conversationHistory: z
.array(z.object({ role: z.enum(['user', 'assistant', 'system']), content: z.string() }))
.optional(),
});
export type WsFloatingRequest = z.infer<typeof WsFloatingRequestSchema>;
// --- Journey frames — Client → Server ----------------------------------------
/** Start a setup journey for custom prompt creation. */
@@ -147,15 +135,22 @@ export const WsTaskBriefRequestSchema = z.object({
});
export type WsTaskBriefRequest = z.infer<typeof WsTaskBriefRequestSchema>;
/** Acknowledgement sent by Electron after persisting a scout_proposal. */
export const WsScoutProposalAckSchema = z.object({
type: z.literal('scout_proposal_ack'),
proposalId: z.string(),
});
export type WsScoutProposalAck = z.infer<typeof WsScoutProposalAckSchema>;
export const WsClientFrameSchema = z.discriminatedUnion('type', [
WsToolResultSchema,
WsDeviceHelloSchema,
WsHomeRequestSchema,
WsFloatingRequestSchema,
WsBriefRequestSchema,
WsTaskBriefRequestSchema,
WsJourneyStartSchema,
WsJourneyMessageSchema,
WsScoutProposalAckSchema,
]);
export type WsClientFrame = z.infer<typeof WsClientFrameSchema>;
@@ -213,20 +208,6 @@ export const WsStreamEndSchema = z.object({
});
export type WsStreamEnd = z.infer<typeof WsStreamEndSchema>;
export const WsFloatingDomainSchema = z.object({
type: z.literal('floating_domain'),
requestId: z.string(),
domain: z.union([
z.enum(['tasks', 'notes', 'timelines', 'projects']),
z.object({
type: z.enum(['task', 'timeline', 'project', 'note', 'node']),
id: z.string().nullable().optional(),
section: z.enum(['task', 'timeline', 'note']).nullable().optional(),
}),
]),
});
export type WsFloatingDomain = z.infer<typeof WsFloatingDomainSchema>;
// --- Journey frames — Server → Client ----------------------------------------
/** Server reply during a setup journey conversation. */
@@ -307,18 +288,34 @@ export const WsIndexSessionDoneSchema = z.object({
});
export type WsIndexSessionDone = z.infer<typeof WsIndexSessionDoneSchema>;
/** Sent by the backend when a cloud scout has a new suggestion to deliver. */
export const WsScoutProposalSchema = z.object({
type: z.literal('scout_proposal'),
proposal: z.object({
id: z.string(),
scoutId: z.string(),
sourceType: z.string(),
sourceMsgRef: z.string(),
rawSubject: z.string().nullable().optional(),
rawSnippet: z.string().nullable().optional(),
category: z.literal('unprocessed'),
payload: z.record(z.string(), z.unknown()).nullable().optional(),
}),
});
export type WsScoutProposal = z.infer<typeof WsScoutProposalSchema>;
export const WsServerFrameSchema = z.discriminatedUnion('type', [
WsToolCallSchema,
WsPingSchema,
WsStreamStartSchema,
WsStreamTextSchema,
WsStreamEndSchema,
WsFloatingDomainSchema,
WsJourneyReplySchema,
WsRunCompleteSchema,
WsIndexFileResultSchema,
WsIndexSessionProgressSchema,
WsIndexSessionDoneSchema,
WsScoutProposalSchema,
]);
export type WsServerFrame = z.infer<typeof WsServerFrameSchema>;
@@ -350,25 +347,25 @@ export const AgentCatalogItemSchema = z.object({
});
export type AgentCatalogItem = z.infer<typeof AgentCatalogItemSchema>;
/** A configured local directory agent stored on the backend. */
export const LocalAgentConfigSchema = z.object({
/** A configured local directory scout stored on the backend. */
export const LocalScoutConfigSchema = z.object({
id: z.string(),
userId: z.string(),
deviceId: z.string(),
name: z.string(),
directoryPaths: z.array(z.string()),
dataTypes: z.array(z.string()),
agentConfig: z.record(z.string(), z.unknown()).nullable(),
scoutConfig: z.record(z.string(), z.unknown()).nullable(),
scheduleCron: z.string(),
enabled: z.boolean(),
lastRunAt: z.number().int().nullable().optional(),
createdAt: z.number().int(),
updatedAt: z.number().int(),
});
export type LocalAgentConfig = z.infer<typeof LocalAgentConfigSchema>;
export type LocalScoutConfig = z.infer<typeof LocalScoutConfigSchema>;
/** A configured cloud connector agent stored on the backend. */
export const CloudAgentConfigSchema = z.object({
/** A configured cloud connector scout stored on the backend. */
export const CloudScoutConfigSchema = z.object({
id: z.string(),
userId: z.string(),
provider: z.enum(['gmail', 'teams', 'outlook']),
@@ -381,8 +378,10 @@ export const CloudAgentConfigSchema = z.object({
lastRunAt: z.number().int().nullable().optional(),
createdAt: z.number().int(),
updatedAt: z.number().int(),
/** True when the scout has an OAuth token stored (never exposes the token itself). */
oauthConnected: z.boolean().optional(),
});
export type CloudAgentConfig = z.infer<typeof CloudAgentConfigSchema>;
export type CloudScoutConfig = z.infer<typeof CloudScoutConfigSchema>;
/** A single agent run log entry returned by GET /api/v1/agents/runs. */
export const AgentRunLogSchema = z.object({