Compare commits
41 Commits
b2d7fa1723
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a4cfb07a5 | ||
|
|
6adb13ff88 | ||
|
|
ff1208fd3c | ||
|
|
3d4aef7fe3 | ||
|
|
5cd895f04e | ||
|
|
49b1d60fca | ||
|
|
b258ec3de5 | ||
|
|
f0a18d7011 | ||
|
|
9b66dc3329 | ||
|
|
c1b1b289c1 | ||
|
|
6aa7cb3d22 | ||
|
|
1f60931a0f | ||
|
|
42a457f973 | ||
|
|
e6357b0d61 | ||
|
|
63fc3cfa43 | ||
|
|
d50be8e7af | ||
|
|
d6b1a86e95 | ||
|
|
ca669a1c5c | ||
|
|
ffd0e97508 | ||
|
|
2bc9617b14 | ||
|
|
3aa7aa0d50 | ||
|
|
8a6befd481 | ||
|
|
652a6b830d | ||
|
|
b2b9607f64 | ||
|
|
bdc9411782 | ||
|
|
8529c3f0b6 | ||
|
|
732235c93a | ||
|
|
539beaf225 | ||
|
|
f9eb4b41b6 | ||
|
|
4e42ac8b04 | ||
|
|
869e0d82ee | ||
|
|
49c0ae2413 | ||
|
|
4b5f379126 | ||
|
|
aad8292f9e | ||
|
|
44a21d662d | ||
|
|
ae2cef4335 | ||
|
|
57462af4f4 | ||
|
|
425025ad68 | ||
|
|
b879760013 | ||
|
|
21aa1db07e | ||
|
|
81fe6d29e2 |
@@ -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
6
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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')}`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
23
src/main/db/migrations/0006_misty_cammi.sql
Normal file
23
src/main/db/migrations/0006_misty_cammi.sql
Normal 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`);
|
||||
44
src/main/db/migrations/0007_scouts_rename.sql
Normal file
44
src/main/db/migrations/0007_scouts_rename.sql
Normal 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`;
|
||||
16
src/main/db/migrations/0008_scout_suggestions.sql
Normal file
16
src/main/db/migrations/0008_scout_suggestions.sql
Normal 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
|
||||
);
|
||||
1052
src/main/db/migrations/meta/0006_snapshot.json
Normal file
1052
src/main/db/migrations/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1058
src/main/db/migrations/meta/0007_snapshot.json
Normal file
1058
src/main/db/migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1153
src/main/db/migrations/meta/0008_snapshot.json
Normal file
1153
src/main/db/migrations/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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() ?? '';
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
105
src/main/router/ai-chat.ts
Normal 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 };
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/main/scouts/scout-suggestion-handler.ts
Normal file
39
src/main/scouts/scout-suggestion-handler.ts
Normal 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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
17
src/renderer/components/ai/AdiuvaIcon.tsx
Normal file
17
src/renderer/components/ai/AdiuvaIcon.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
17
src/renderer/components/ai/AdiuvaTriggerButton.tsx
Normal file
17
src/renderer/components/ai/AdiuvaTriggerButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
502
src/renderer/components/ai/ChatSurface.tsx
Normal file
502
src/renderer/components/ai/ChatSurface.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
85
src/renderer/components/ai/ContextualSidebar.tsx
Normal file
85
src/renderer/components/ai/ContextualSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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[]>([]);
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
165
src/renderer/components/settings/ScoutsSection.tsx
Normal file
165
src/renderer/components/settings/ScoutsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
279
src/renderer/context/ContextualChatContext.tsx
Normal file
279
src/renderer/context/ContextualChatContext.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
40
src/renderer/context/HeaderContext.tsx
Normal file
40
src/renderer/context/HeaderContext.tsx
Normal 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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
97
src/renderer/hooks/useChatStream.ts
Normal file
97
src/renderer/hooks/useChatStream.ts
Normal 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 };
|
||||
}
|
||||
12
src/renderer/hooks/useContextualScope.ts
Normal file
12
src/renderer/hooks/useContextualScope.ts
Normal 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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
27
src/renderer/hooks/useHeaderSlot.ts
Normal file
27
src/renderer/hooks/useHeaderSlot.ts
Normal 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]);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user