Compare commits
141 Commits
dd3f1442b0
...
4b80bcb53b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b80bcb53b | ||
|
|
55c1bab7b1 | ||
|
|
60925da98c | ||
|
|
93674456ec | ||
|
|
a7b9d51268 | ||
|
|
9733aa6a3a | ||
|
|
e8f56feaac | ||
|
|
b6468c755f | ||
|
|
6868d8813e | ||
|
|
486ff83a94 | ||
|
|
0a893d1929 | ||
|
|
e6d3f9d7be | ||
|
|
3b41e8e7aa | ||
|
|
4979c2b7d9 | ||
|
|
7bd4cc9d9e | ||
|
|
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 | ||
|
|
b2d7fa1723 | ||
|
|
4c641ab93a | ||
|
|
84720ff23c | ||
|
|
d7307e146a | ||
|
|
7d4059ca4b | ||
|
|
9691842e79 | ||
|
|
094840e671 | ||
|
|
e8592b25a8 | ||
|
|
27b385df53 | ||
|
|
e170844f17 | ||
|
|
27c1194384 | ||
|
|
26ea095f60 | ||
|
|
751d16a9f4 | ||
|
|
285214a2d2 | ||
|
|
89645f2abd | ||
|
|
7dadeb88fe | ||
|
|
13531fec40 | ||
|
|
e254efd420 | ||
|
|
6d79911414 | ||
|
|
69a859e19f | ||
|
|
098ce86c76 | ||
|
|
9ef809ba02 | ||
|
|
024d572ebb | ||
|
|
d24f09bbea | ||
|
|
56fe6c0754 | ||
|
|
c76de207d7 | ||
|
|
4e89a7a96c | ||
|
|
0fc3aa421e | ||
|
|
c10fbe22d7 | ||
|
|
e3e0b06fb6 | ||
|
|
b3d85b93f1 | ||
|
|
659607a1e9 | ||
|
|
80a0d2c56f | ||
|
|
66448a25f4 | ||
|
|
93144b9de8 | ||
|
|
b0c415f90f | ||
|
|
8a2225da7c | ||
|
|
e0c5971d20 | ||
|
|
a499d55636 | ||
|
|
c36890cc8b | ||
|
|
b80ba0434b | ||
|
|
01d3735dd1 | ||
|
|
e0bcb2fe0a | ||
|
|
a1c83a6134 | ||
|
|
bd5e3076ed | ||
|
|
316b8fa66a | ||
|
|
6f907f6a96 | ||
|
|
93caf0116d | ||
|
|
15af8d54e6 | ||
|
|
c4ed7b3482 | ||
|
|
066d407a5f | ||
|
|
c2826ae4be | ||
|
|
adb1cc81ef | ||
|
|
a4fd10e640 | ||
|
|
efa3051c61 | ||
|
|
72e09501de | ||
|
|
875fe625b5 | ||
|
|
dac1d50b02 | ||
|
|
e104ffc3ab | ||
|
|
1cffb9bdbf | ||
|
|
bae84f1a48 | ||
|
|
938c8eef8a | ||
|
|
50d01c7aec | ||
|
|
ef04bec66f | ||
|
|
2e9ec31d83 | ||
|
|
ca290225b9 | ||
|
|
a5ec0647ec | ||
|
|
57f5470f0d | ||
|
|
33e5edc2ba | ||
|
|
fadda94135 | ||
|
|
5fa3df9c16 | ||
|
|
b48ceea0af | ||
|
|
9e31cfa78e | ||
|
|
c63c94b561 | ||
|
|
cbdb37f5a5 | ||
|
|
05de7405ba | ||
|
|
68286b61bd | ||
|
|
a7fbc4c7e3 | ||
|
|
1a5605569c | ||
|
|
ef71710244 | ||
|
|
ca78a4cbc0 | ||
|
|
b652248404 | ||
|
|
f5ac37867c | ||
|
|
37878df992 | ||
|
|
9e90791743 |
@@ -110,7 +110,7 @@ All use `temperature: 0.3`, streaming enabled. Provider management in `provider.
|
||||
|
||||
### Notes AI Navigation (aiSummary index)
|
||||
|
||||
Notes have `aiSummary` (≤250 char, nullable) and `aiSummaryUpdatedAt` columns. Generated by backend `POST /api/v1/agents/notes/summarize` (gpt-4o-mini, Langfuse `note_summary` prompt).
|
||||
Notes have `aiSummary` (≤250 char, nullable) and `aiSummaryUpdatedAt` columns. Generated by backend `POST /api/v1/scouts/notes/summarize` (gpt-4o-mini, Langfuse `note_summary` prompt).
|
||||
|
||||
- `list_notes` tool output includes the summary per note so AI can navigate without reading full content.
|
||||
- `notes-backfill.ts` generates missing summaries on startup (throttled 1 req/s, skipped when offline).
|
||||
@@ -131,6 +131,7 @@ Tasks and checkpoints have `isAiSuggested` + `isApproved` columns. AI suggestion
|
||||
|
||||
## Config Notes
|
||||
|
||||
- **BackendClient case conversion (footgun)**: `backend-client.ts` `proxyGet`/`proxyPost`/`proxyPut` **camelCase the response** and **snake_case the request body**. The FastAPI BE speaks snake_case; the TS side speaks camelCase. So a BE response `{authorize_url}` arrives as `{authorizeUrl}` — reading `data.authorize_url` returns `undefined`. Always type the proxy generic with camelCase keys and read camelCase. (Real bug: `shell.openExternal(data.authorize_url)` → `undefined` → Electron throws `Error processing argument at index 0, conversion failure from undefined`.)
|
||||
- Vite configs use `.mts` (not `.ts`) — avoids ESM/CJS conflicts with electron-forge
|
||||
- `@/*` path alias → `src/renderer/*` (TypeScript + Vite + shadcn/ui)
|
||||
- **shadcn/ui**: new-york style, neutral base color
|
||||
|
||||
6
.gitignore
vendored
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')}`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -29,6 +29,10 @@ const config: ForgeConfig = {
|
||||
name: 'adiuvAI',
|
||||
// icon path without extension — Forge picks .ico (Win), .icns (Mac), .png (Linux)
|
||||
icon: 'assets/logo/logo-icon',
|
||||
// Ship Drizzle's generated migrations as a sibling of the asar so the
|
||||
// runtime migrator (drizzle-orm/better-sqlite3/migrator) can read them at
|
||||
// `<resourcesPath>/migrations/` in packaged builds. See src/main/db/index.ts.
|
||||
extraResource: ['./src/main/db/migrations'],
|
||||
// Deep-link protocol for OAuth callback: adiuvai://oauth/callback?code=...
|
||||
// macOS: written into Info.plist by Forge automatically.
|
||||
// Windows: registered by the Squirrel installer via packagerConfig.protocols.
|
||||
|
||||
52
scripts/check_scouts_db.py
Normal file
52
scripts/check_scouts_db.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Inspect Electron's SQLite for scout tables + suggestions.
|
||||
|
||||
Usage:
|
||||
python adiuvAI/scripts/check_scouts_db.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
DB_PATH = os.path.join(os.environ["APPDATA"], "adiuvAI", "adiuvai.db")
|
||||
|
||||
if not os.path.exists(DB_PATH):
|
||||
print(f"DB not found: {DB_PATH}")
|
||||
sys.exit(1)
|
||||
|
||||
c = sqlite3.connect(DB_PATH)
|
||||
|
||||
print(f"DB: {DB_PATH}\n")
|
||||
|
||||
print("=== scout_* tables ===")
|
||||
for row in c.execute(
|
||||
"select name from sqlite_master where type='table' and name like 'scout_%' order by name"
|
||||
):
|
||||
print(f" {row[0]}")
|
||||
|
||||
print("\n=== agent_* tables (should be empty post-rename) ===")
|
||||
for row in c.execute(
|
||||
"select name from sqlite_master where type='table' and name like 'agent_%' order by name"
|
||||
):
|
||||
print(f" {row[0]}")
|
||||
|
||||
print("\n=== Migration ledger (last 5) ===")
|
||||
for row in c.execute(
|
||||
"select hash, created_at from __drizzle_migrations order by id desc limit 5"
|
||||
):
|
||||
print(f" {row[0][:20]}... {row[1]}")
|
||||
|
||||
print("\n=== scout_suggestions rows (latest 5) ===")
|
||||
try:
|
||||
rows = list(
|
||||
c.execute(
|
||||
"select id, raw_subject, status, category, proposed_at "
|
||||
"from scout_suggestions order by proposed_at desc limit 5"
|
||||
)
|
||||
)
|
||||
if not rows:
|
||||
print(" (empty)")
|
||||
for row in rows:
|
||||
print(f" {row}")
|
||||
except sqlite3.OperationalError as e:
|
||||
print(f" ERROR: {e}")
|
||||
@@ -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,21 +20,20 @@
|
||||
import { app } from 'electron';
|
||||
import WebSocket from 'ws';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getStore, getDeviceId, getLocalAgents, getFormatPrefs } from '../store';
|
||||
import { getStore, getDeviceId, getLocalScouts, getFormatPrefs } from '../store';
|
||||
import { detectFormatPrefs } from '../auth/locale-defaults';
|
||||
import { getAuthManager } from '../auth/auth-manager';
|
||||
import { toSnakeCase, toCamelCase } from '../../shared/casing';
|
||||
import { handleScoutProposal } from '../scouts/scout-suggestion-handler';
|
||||
import {
|
||||
WsServerFrameSchema,
|
||||
} from '../../shared/api-types';
|
||||
import type {
|
||||
WsToolResult,
|
||||
WsFloatingRequest,
|
||||
WsFloatingDomain,
|
||||
} from '../../shared/api-types';
|
||||
import { DrizzleExecutor } from './drizzle-executor';
|
||||
import { getDb } from '../db';
|
||||
import { agentRuns, agentRunActions } from '../db/schema';
|
||||
import { scoutRuns, scoutRunActions } from '../db/schema';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent run logging helpers
|
||||
@@ -62,10 +61,10 @@ async function recordRunAction(
|
||||
entityTitle: string | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await getDb().insert(agentRunActions).values({
|
||||
await getDb().insert(scoutRunActions).values({
|
||||
id: crypto.randomUUID(),
|
||||
runId,
|
||||
agentId,
|
||||
scoutId: agentId,
|
||||
verb,
|
||||
entityType,
|
||||
entityId: entityId ?? null,
|
||||
@@ -163,6 +162,16 @@ export class ServerError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class QuotaError extends Error {
|
||||
constructor(
|
||||
public readonly reason: 'max_files' | 'monthly_tokens',
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'QuotaError';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V3 stream listener types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -171,7 +180,6 @@ interface StreamListener {
|
||||
onStart: () => void;
|
||||
onText: (chunk: string) => void;
|
||||
onEnd: (mutations?: unknown) => void;
|
||||
onDomain: (domain: WsFloatingDomain['domain']) => void;
|
||||
onError: (err: Error) => void;
|
||||
resolve: () => void;
|
||||
reject: (err: Error) => void;
|
||||
@@ -183,6 +191,12 @@ interface JourneyListener {
|
||||
reject: (err: Error) => void;
|
||||
}
|
||||
|
||||
export interface IndexSessionListener {
|
||||
onFileResult: (frame: { relPath: string; summary: string | null; tokensUsed: number; error?: string }) => void;
|
||||
onProgress: (frame: { processed: number; total: number }) => void;
|
||||
onDone: (status: 'completed' | 'cancelled' | 'quota_exceeded' | 'error') => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BackendClient
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -206,6 +220,9 @@ export class BackendClient {
|
||||
/** Journey reply listeners keyed by sessionId. */
|
||||
private journeyListeners: Map<string, JourneyListener> = new Map();
|
||||
|
||||
/** Index session listeners keyed by sessionId. */
|
||||
private indexListeners: Map<string, IndexSessionListener> = new Map();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
@@ -273,7 +290,6 @@ export class BackendClient {
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
resolve();
|
||||
},
|
||||
onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
|
||||
onError: (err) => {
|
||||
callbacks?.onError?.(err);
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
@@ -333,7 +349,6 @@ export class BackendClient {
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
resolve();
|
||||
},
|
||||
onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
|
||||
onError: (err) => {
|
||||
callbacks?.onError?.(err);
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
@@ -372,72 +387,6 @@ export class BackendClient {
|
||||
return { requestId: activeRequestId, promise };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a floating chat request over the persistent device WS.
|
||||
* Same listener pattern as `sendHomeRequest`.
|
||||
*/
|
||||
sendFloatingRequest(
|
||||
message: string,
|
||||
scope: WsFloatingRequest['scope'],
|
||||
conversationHistory?: WsFloatingRequest['conversationHistory'],
|
||||
requestId?: string,
|
||||
sessionId?: string,
|
||||
briefMode?: boolean,
|
||||
briefingContext?: string,
|
||||
callbacks?: Partial<Omit<StreamListener, 'resolve' | 'reject'>>,
|
||||
): { requestId: string; promise: Promise<void> } {
|
||||
const activeRequestId = requestId ?? crypto.randomUUID();
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
this.streamListeners.set(activeRequestId, {
|
||||
onStart: callbacks?.onStart ?? ((): void => { /* no-op */ }),
|
||||
onText: callbacks?.onText ?? ((): void => { /* no-op */ }),
|
||||
onEnd: (mutations) => {
|
||||
callbacks?.onEnd?.(mutations);
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
resolve();
|
||||
},
|
||||
onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
|
||||
onError: (err) => {
|
||||
callbacks?.onError?.(err);
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
reject(err);
|
||||
},
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
|
||||
const ws = this.persistentWs;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
reject(new OfflineError('Persistent WS not connected'));
|
||||
return;
|
||||
}
|
||||
|
||||
const rawPrefs = getFormatPrefs() ?? detectFormatPrefs();
|
||||
const floatingPayload = toSnakeCase({
|
||||
type: 'floating_request',
|
||||
requestId: activeRequestId,
|
||||
sessionId,
|
||||
message,
|
||||
scope,
|
||||
conversationHistory,
|
||||
briefMode: briefMode ?? false,
|
||||
briefingContext: briefingContext ?? null,
|
||||
formatPrefs: {
|
||||
timezone: rawPrefs.timezone,
|
||||
dateFormat: rawPrefs.dateFormat,
|
||||
timeFormat: rawPrefs.timeFormat,
|
||||
locale: app.getLocale(),
|
||||
nowIso: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
logWsSend(floatingPayload);
|
||||
ws.send(JSON.stringify(floatingPayload));
|
||||
});
|
||||
|
||||
return { requestId: activeRequestId, promise };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a task brief research request over the persistent device WS.
|
||||
* Backend runs a deep-research Stage 1 agent and streams the briefing.
|
||||
@@ -458,7 +407,6 @@ export class BackendClient {
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
resolve();
|
||||
},
|
||||
onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
|
||||
onError: (err) => {
|
||||
callbacks?.onError?.(err);
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
@@ -564,6 +512,150 @@ export class BackendClient {
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Index session WS methods
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Register a listener for an index session. Must be called before sending
|
||||
* the first `index_session_start` for that sessionId. Listener is auto-
|
||||
* removed when `index_session_done` is received.
|
||||
*/
|
||||
registerIndexSession(sessionId: string, listener: IndexSessionListener): void {
|
||||
this.indexListeners.set(sessionId, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the opening `index_session_start` frame. Must be called after
|
||||
* `registerIndexSession`. Throws OfflineError if the WS is not connected.
|
||||
*/
|
||||
sendIndexSessionStart(sessionId: string, projectId: string, totalFiles: number): void {
|
||||
const ws = this.persistentWs;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) throw new OfflineError('Persistent WS not connected');
|
||||
const payload = toSnakeCase({ type: 'index_session_start', sessionId, projectId, totalFiles });
|
||||
logWsSend(payload);
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one batch of files (typically up to 5).
|
||||
*/
|
||||
sendIndexFileBatch(
|
||||
sessionId: string,
|
||||
files: Array<{
|
||||
relPath: string;
|
||||
kind: 'text' | 'image' | 'pdf' | 'docx';
|
||||
content: string;
|
||||
ext?: string;
|
||||
mime?: string;
|
||||
sizeBytes: number;
|
||||
mtimeMs: number;
|
||||
}>,
|
||||
): void {
|
||||
const ws = this.persistentWs;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) throw new OfflineError('Persistent WS not connected');
|
||||
const payload = toSnakeCase({ type: 'index_file_batch', sessionId, files });
|
||||
logWsSend(payload);
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an in-flight index session.
|
||||
*/
|
||||
sendIndexSessionCancel(sessionId: string): void {
|
||||
const ws = this.persistentWs;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return; // best-effort
|
||||
const payload = toSnakeCase({ type: 'index_session_cancel', sessionId });
|
||||
logWsSend(payload);
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Contextual chat — send via persistent device WS
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Send a contextual chat request over the persistent device WS.
|
||||
* Same listener pattern as `sendHomeRequest` but uses the
|
||||
* `contextual_request` frame type.
|
||||
*/
|
||||
sendContextualRequest(
|
||||
message: string,
|
||||
scope: unknown,
|
||||
conversationHistory?: Array<{ role: string; content: string }>,
|
||||
requestId?: string,
|
||||
sessionId?: string,
|
||||
callbacks?: Partial<Omit<StreamListener, 'resolve' | 'reject'>>,
|
||||
): { requestId: string; promise: Promise<void> } {
|
||||
const activeRequestId = requestId ?? crypto.randomUUID();
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
this.streamListeners.set(activeRequestId, {
|
||||
onStart: callbacks?.onStart ?? ((): void => { /* no-op */ }),
|
||||
onText: callbacks?.onText ?? ((): void => { /* no-op */ }),
|
||||
onEnd: (mutations) => {
|
||||
callbacks?.onEnd?.(mutations);
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
resolve();
|
||||
},
|
||||
onError: (err) => {
|
||||
callbacks?.onError?.(err);
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
reject(err);
|
||||
},
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
|
||||
const ws = this.persistentWs;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
reject(new OfflineError('Persistent WS not connected'));
|
||||
return;
|
||||
}
|
||||
|
||||
const rawPrefs = getFormatPrefs() ?? detectFormatPrefs();
|
||||
const contextualPayload = toSnakeCase({
|
||||
type: 'contextual_request',
|
||||
requestId: activeRequestId,
|
||||
sessionId,
|
||||
message,
|
||||
scope,
|
||||
conversationHistory,
|
||||
formatPrefs: {
|
||||
timezone: rawPrefs.timezone,
|
||||
dateFormat: rawPrefs.dateFormat,
|
||||
timeFormat: rawPrefs.timeFormat,
|
||||
locale: app.getLocale(),
|
||||
nowIso: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
logWsSend(contextualPayload);
|
||||
ws.send(JSON.stringify(contextualPayload));
|
||||
});
|
||||
|
||||
return { requestId: activeRequestId, promise };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a contextual scope update over the persistent device WS.
|
||||
* Fire-and-forget — backend responds with `contextual_scope_ack` which
|
||||
* we do not need to handle on the Electron side.
|
||||
*/
|
||||
sendContextualScopeUpdate(args: { sessionId: string; scope: unknown }): void {
|
||||
const ws = this.persistentWs;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn('[DeviceWS] sendContextualScopeUpdate: WS not connected — dropping scope update.');
|
||||
return;
|
||||
}
|
||||
const payload = toSnakeCase({
|
||||
type: 'contextual_scope_update',
|
||||
sessionId: args.sessionId,
|
||||
scope: args.scope,
|
||||
});
|
||||
logWsSend(payload);
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// HTTP utilities
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -678,6 +770,72 @@ export class BackendClient {
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Billing quota pre-flight
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pre-flight quota check for folder indexing.
|
||||
*
|
||||
* Calls `POST /api/v1/billing/quota/check` with `{ feature: "folder_index",
|
||||
* estimated_files: estimatedFiles }`.
|
||||
*
|
||||
* Returns `{ ok: true }` when the backend allows the operation.
|
||||
* Throws `QuotaError` when the backend responds with HTTP 402.
|
||||
* Propagates `AuthExpiredError` / `OfflineError` on auth / network failure.
|
||||
*/
|
||||
async checkFolderQuota(estimatedFiles: number): Promise<{ ok: true }> {
|
||||
const token = await getAuthManager().getAccessToken();
|
||||
if (!token) throw new AuthExpiredError();
|
||||
|
||||
const url = `${this.baseUrl}/api/v1/billing/quota/check`;
|
||||
const bodyPayload = { feature: 'folder_index', estimated_files: estimatedFiles };
|
||||
logHttp('POST', url, bodyPayload);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(bodyPayload),
|
||||
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
throw new OfflineError(err instanceof Error ? err.message : 'Network error');
|
||||
}
|
||||
|
||||
logHttpResponse('POST', url, res.status);
|
||||
|
||||
if (res.ok) return { ok: true };
|
||||
|
||||
if (res.status === 402) {
|
||||
let detail: { reason: 'max_files' | 'monthly_tokens'; message: string } | null = null;
|
||||
try {
|
||||
const body = await res.json() as { detail?: { reason: string; message: string } };
|
||||
if (body.detail?.reason && body.detail?.message) {
|
||||
detail = {
|
||||
reason: body.detail.reason as 'max_files' | 'monthly_tokens',
|
||||
message: body.detail.message,
|
||||
};
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
const reason = detail?.reason ?? 'max_files';
|
||||
const message = detail?.message ?? 'Quota exceeded';
|
||||
throw new QuotaError(reason, message);
|
||||
}
|
||||
|
||||
// Other error codes
|
||||
const text = await res.text().catch(() => '');
|
||||
const msg = `${res.status} ${res.statusText}${text ? `: ${text}` : ''}`;
|
||||
if (res.status === 401) throw new AuthExpiredError(msg);
|
||||
if (res.status === 429) throw new RateLimitError(msg);
|
||||
if (res.status >= 500) throw new ServerError(msg, res.status);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -744,14 +902,14 @@ export class BackendClient {
|
||||
this.reconnectAttempt = 0;
|
||||
console.log('[DeviceWS] Connected.');
|
||||
|
||||
// Read enabled local agent IDs from local storage
|
||||
// Read enabled local scout IDs from local storage
|
||||
const deviceId = getDeviceId();
|
||||
const agentIds = getLocalAgents()
|
||||
.filter((a) => a.enabled)
|
||||
.map((a) => a.id);
|
||||
const scoutIds = getLocalScouts()
|
||||
.filter((s) => s.enabled)
|
||||
.map((s) => s.id);
|
||||
|
||||
ws.send(JSON.stringify(toSnakeCase({ type: 'device_hello', deviceId, agentIds })));
|
||||
console.log(`[DeviceWS] Sent device_hello (deviceId=${deviceId}, agents=${agentIds.length}).`);
|
||||
ws.send(JSON.stringify(toSnakeCase({ type: 'device_hello', deviceId, scoutIds })));
|
||||
console.log(`[DeviceWS] Sent device_hello (deviceId=${deviceId}, scouts=${scoutIds.length}).`);
|
||||
this.startHeartbeat(ws);
|
||||
});
|
||||
|
||||
@@ -765,8 +923,13 @@ export class BackendClient {
|
||||
}
|
||||
|
||||
logWsRecv(parsed);
|
||||
const frame = WsServerFrameSchema.safeParse(toCamelCase(parsed));
|
||||
if (!frame.success) return;
|
||||
const camel = toCamelCase(parsed);
|
||||
const frame = WsServerFrameSchema.safeParse(camel);
|
||||
if (!frame.success) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[ws] frame parse FAILED. type=', (camel as { type?: string })?.type, 'issues=', JSON.stringify(frame.error.issues));
|
||||
return;
|
||||
}
|
||||
|
||||
// Any incoming frame resets the pong timeout
|
||||
this.clearPongTimer();
|
||||
@@ -827,20 +990,14 @@ export class BackendClient {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'floating_domain': {
|
||||
const listener = this.streamListeners.get(frame.data.requestId);
|
||||
listener?.onDomain(frame.data.domain);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'run_complete': {
|
||||
const { runContext, status } = frame.data;
|
||||
void (async () => {
|
||||
try {
|
||||
const db = getDb();
|
||||
await db.update(agentRuns)
|
||||
await db.update(scoutRuns)
|
||||
.set({ status: status === 'success' ? 'completed' : status === 'partial' ? 'partial' : 'failed', completedAt: Date.now() })
|
||||
.where(eq(agentRuns.id, runContext.runId));
|
||||
.where(eq(scoutRuns.id, runContext.runId));
|
||||
} catch (err) {
|
||||
console.warn('[RunLog] Failed to close run:', err);
|
||||
}
|
||||
@@ -861,6 +1018,50 @@ export class BackendClient {
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'index_file_result': {
|
||||
const lis = this.indexListeners.get(frame.data.sessionId);
|
||||
lis?.onFileResult({
|
||||
relPath: frame.data.relPath,
|
||||
summary: frame.data.summary ?? null,
|
||||
tokensUsed: frame.data.tokensUsed,
|
||||
error: frame.data.error,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'index_session_progress': {
|
||||
const lis = this.indexListeners.get(frame.data.sessionId);
|
||||
lis?.onProgress({ processed: frame.data.processed, total: frame.data.total });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'index_session_done': {
|
||||
const lis = this.indexListeners.get(frame.data.sessionId);
|
||||
if (lis) {
|
||||
lis.onDone(frame.data.status);
|
||||
this.indexListeners.delete(frame.data.sessionId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'scout_proposal': {
|
||||
const proposal = frame.data.proposal;
|
||||
void (async () => {
|
||||
try {
|
||||
await handleScoutProposal(proposal);
|
||||
// Ack only on successful persist — if this fails, BE will re-deliver on next reconnect.
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const ack = toSnakeCase({ type: 'scout_proposal_ack', proposalId: proposal.id });
|
||||
logWsSend(ack);
|
||||
ws.send(JSON.stringify(ack));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[scout-proposal] persist failed, not acking:', err);
|
||||
}
|
||||
})();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -895,6 +1096,8 @@ export class BackendClient {
|
||||
}
|
||||
this.journeyListeners.clear();
|
||||
}
|
||||
// Index session listeners are fire-and-forget; just drop them on disconnect.
|
||||
this.indexListeners.clear();
|
||||
|
||||
if (this.shouldReconnect) {
|
||||
this.scheduleReconnect();
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { eq, and, or, like, isNull, asc, desc, gte, lte, sql, SQL } from 'drizzle-orm';
|
||||
import { eq, and, or, like, isNull, asc, desc, gte, lte, inArray, sql, SQL } from 'drizzle-orm';
|
||||
import { getDb } from '../db';
|
||||
import { tasks, projects, clients, timelineEvents, notes, noteEdits, taskComments } from '../db/schema';
|
||||
import { tasks, projects, clients, timelineEvents, notes, noteEdits, taskComments, projectFolderFiles } from '../db/schema';
|
||||
import type { WsToolCall } from '../../shared/api-types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -31,6 +31,7 @@ const TABLE_REGISTRY = {
|
||||
timelineEvents,
|
||||
// Alias: the backend sends "timelines" as the table name
|
||||
timelines: timelineEvents,
|
||||
projectFolderFiles,
|
||||
} as const;
|
||||
|
||||
type TableName = keyof typeof TABLE_REGISTRY;
|
||||
@@ -99,6 +100,20 @@ function buildConditions(
|
||||
|
||||
if (value === null) {
|
||||
conditions.push(isNull(col as Parameters<typeof isNull>[0]));
|
||||
} else if (typeof value === 'string' && value.includes(',')) {
|
||||
const parts = value.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
if (parts.length > 1) {
|
||||
conditions.push(inArray(col as Parameters<typeof inArray>[0], parts));
|
||||
} else if (parts.length === 1) {
|
||||
conditions.push(eq(col as Parameters<typeof eq>[0], parts[0]));
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
const parts = value.map((v) => String(v)).filter(Boolean);
|
||||
if (parts.length > 1) {
|
||||
conditions.push(inArray(col as Parameters<typeof inArray>[0], parts));
|
||||
} else if (parts.length === 1) {
|
||||
conditions.push(eq(col as Parameters<typeof eq>[0], parts[0]));
|
||||
}
|
||||
} else {
|
||||
conditions.push(eq(col as Parameters<typeof eq>[0], value as Parameters<typeof eq>[1]));
|
||||
}
|
||||
@@ -187,6 +202,14 @@ export class DrizzleExecutor {
|
||||
return this.handleReadFileContent(payload);
|
||||
case 'get_file_metadata':
|
||||
return this.handleGetFileMetadata(payload);
|
||||
case 'read_project_folder_manifest':
|
||||
return this.handleReadProjectFolderManifest(payload);
|
||||
case 'read_project_folder_file':
|
||||
return this.handleReadProjectFolderFile(payload);
|
||||
case 'list_projects_with_folder_manifests':
|
||||
return this.handleListProjectsWithFolderManifests();
|
||||
case 'get_page_details':
|
||||
return this.handleGetPageDetails(payload);
|
||||
default:
|
||||
throw new ExecutorError(`Unknown action: "${action as string}"`);
|
||||
}
|
||||
@@ -436,4 +459,209 @@ export class DrizzleExecutor {
|
||||
modifiedAt: stat.mtime.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Project folder handlers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private handleReadProjectFolderManifest(payload: WsToolCall): Record<string, unknown> {
|
||||
const { projectId } = (payload.data ?? {}) as { projectId: string };
|
||||
const proj = getDb().select().from(projects).where(eq(projects.id, projectId)).get();
|
||||
if (!proj?.folderPath) return { folderPath: null, lastScannedAt: null, files: [] };
|
||||
|
||||
const files = getDb()
|
||||
.select({
|
||||
relPath: projectFolderFiles.relativePath,
|
||||
kind: projectFolderFiles.kind,
|
||||
summary: projectFolderFiles.summary,
|
||||
mtimeMs: projectFolderFiles.mtimeMs,
|
||||
})
|
||||
.from(projectFolderFiles)
|
||||
.where(eq(projectFolderFiles.projectId, projectId))
|
||||
.all();
|
||||
|
||||
// On-demand mtime check: if not currently scanning, fire-and-forget rescan when deltas exist.
|
||||
// Returns the current (possibly stale) manifest immediately; the rescan updates rows
|
||||
// for the next call.
|
||||
if (proj.folderLastScanStatus !== 'scanning') {
|
||||
void import('../files/scanner')
|
||||
.then(async ({ scanFolder }) => {
|
||||
const delta = await scanFolder(projectId, proj.folderPath!);
|
||||
if (
|
||||
delta.newFiles.length > 0 ||
|
||||
delta.changedFiles.length > 0 ||
|
||||
delta.deletedRelPaths.length > 0
|
||||
) {
|
||||
const { startIndexSession } = await import('../files/indexer');
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
void startIndexSession(projectId, () => {});
|
||||
}
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
return {
|
||||
folderPath: proj.folderPath,
|
||||
lastScannedAt: proj.folderLastScannedAt,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleReadProjectFolderFile(payload: WsToolCall): Promise<Record<string, unknown>> {
|
||||
const { projectId, relativePath, offset, length } = (payload.data ?? {}) as {
|
||||
projectId: string;
|
||||
relativePath: string;
|
||||
offset?: number;
|
||||
length?: number;
|
||||
};
|
||||
|
||||
if (!relativePath || relativePath.includes('..') || path.isAbsolute(relativePath)) {
|
||||
throw new ExecutorError('Access denied');
|
||||
}
|
||||
|
||||
const proj = getDb().select().from(projects).where(eq(projects.id, projectId)).get();
|
||||
if (!proj?.folderPath) return { content: '', kind: 'missing', totalSize: 0 };
|
||||
|
||||
const abs = path.join(proj.folderPath, relativePath);
|
||||
if (!path.resolve(abs).startsWith(path.resolve(proj.folderPath))) {
|
||||
throw new ExecutorError('Access denied');
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.stat(abs);
|
||||
const ext = path.extname(relativePath).toLowerCase();
|
||||
|
||||
if (['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
|
||||
const buf = await fs.promises.readFile(abs);
|
||||
return { content: buf.toString('base64'), kind: 'image', totalSize: stat.size };
|
||||
}
|
||||
|
||||
// PDF + DOCX: return full base64; backend extracts text + slices.
|
||||
if (ext === '.pdf' || ext === '.docx') {
|
||||
const buf = await fs.promises.readFile(abs);
|
||||
return {
|
||||
content: buf.toString('base64'),
|
||||
kind: ext === '.pdf' ? 'pdf' : 'docx',
|
||||
totalSize: stat.size,
|
||||
};
|
||||
}
|
||||
|
||||
// Text: slice at offset/length on Electron side to keep WS payload small.
|
||||
const start = Math.max(0, offset ?? 0);
|
||||
const want = Math.max(1, Math.min(length ?? MAX_READ_SIZE_BYTES, MAX_READ_SIZE_BYTES));
|
||||
const end = Math.min(start + want, stat.size);
|
||||
const len = Math.max(0, end - start);
|
||||
if (len === 0) {
|
||||
return { content: '', kind: 'text', totalSize: stat.size };
|
||||
}
|
||||
const buf = Buffer.alloc(len);
|
||||
const fd = await fs.promises.open(abs, 'r');
|
||||
try {
|
||||
await fd.read(buf, 0, len, start);
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
return { content: buf.toString('utf8'), kind: 'text', totalSize: stat.size };
|
||||
} catch {
|
||||
return { content: '', kind: 'error', totalSize: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Contextual agent: composite read op
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private handleGetPageDetails(payload: WsToolCall): Record<string, unknown> {
|
||||
const db = getDb();
|
||||
// entity_type is sent as the `table` field by the backend execute_on_client call.
|
||||
const entityType = payload.table ?? '';
|
||||
const entityId = (payload.data?.['entityId'] as string | null | undefined) ?? undefined;
|
||||
|
||||
switch (entityType) {
|
||||
case 'project': {
|
||||
if (!entityId) throw new ExecutorError('get_page_details: entityId required for project');
|
||||
const project = db.select().from(projects).where(eq(projects.id, entityId)).get() ?? null;
|
||||
if (!project) return { project: null, tasks: [], notes: [], milestones: [], events: [] };
|
||||
const projectTasks = db.select().from(tasks).where(eq(tasks.projectId, entityId)).all();
|
||||
const projectNotes = db
|
||||
.select({
|
||||
id: notes.id,
|
||||
title: notes.title,
|
||||
aiSummary: notes.aiSummary,
|
||||
updatedAt: notes.updatedAt,
|
||||
})
|
||||
.from(notes)
|
||||
.where(eq(notes.projectId, entityId))
|
||||
.all();
|
||||
const events = db.select().from(timelineEvents).where(eq(timelineEvents.projectId, entityId)).all();
|
||||
return {
|
||||
project,
|
||||
tasks: projectTasks,
|
||||
notes: projectNotes,
|
||||
milestones: events.filter((e) => e.type === 'milestone'),
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
case 'task': {
|
||||
if (!entityId) throw new ExecutorError('get_page_details: entityId required for task');
|
||||
const task = db.select().from(tasks).where(eq(tasks.id, entityId)).get() ?? null;
|
||||
const project = task?.projectId
|
||||
? (db.select().from(projects).where(eq(projects.id, task.projectId)).get() ?? null)
|
||||
: null;
|
||||
const comments = db.select().from(taskComments).where(eq(taskComments.taskId, entityId)).all();
|
||||
return { task, project, comments };
|
||||
}
|
||||
|
||||
case 'note': {
|
||||
if (!entityId) throw new ExecutorError('get_page_details: entityId required for note');
|
||||
const note = db.select().from(notes).where(eq(notes.id, entityId)).get() ?? null;
|
||||
return { note };
|
||||
}
|
||||
|
||||
case 'tasks_all':
|
||||
return { tasks: db.select().from(tasks).all() };
|
||||
|
||||
case 'projects_all':
|
||||
return { projects: db.select().from(projects).all() };
|
||||
|
||||
case 'timeline_all':
|
||||
return { events: db.select().from(timelineEvents).all() };
|
||||
|
||||
default:
|
||||
throw new ExecutorError(`get_page_details: unknown entityType "${entityType}"`);
|
||||
}
|
||||
}
|
||||
|
||||
private handleListProjectsWithFolderManifests(): Record<string, unknown> {
|
||||
const projs = getDb()
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(sql`${projects.folderPath} IS NOT NULL`)
|
||||
.all();
|
||||
|
||||
const out: Array<unknown> = [];
|
||||
for (const p of projs) {
|
||||
const files = getDb()
|
||||
.select({
|
||||
relPath: projectFolderFiles.relativePath,
|
||||
kind: projectFolderFiles.kind,
|
||||
summary: projectFolderFiles.summary,
|
||||
mtimeMs: projectFolderFiles.mtimeMs,
|
||||
})
|
||||
.from(projectFolderFiles)
|
||||
.where(eq(projectFolderFiles.projectId, p.id))
|
||||
.all();
|
||||
out.push({
|
||||
projectId: p.id,
|
||||
projectName: p.name,
|
||||
folderPath: p.folderPath,
|
||||
lastScannedAt: p.folderLastScannedAt,
|
||||
files,
|
||||
});
|
||||
}
|
||||
|
||||
return { projects: out };
|
||||
}
|
||||
}
|
||||
|
||||
49
src/main/attachments/storage.ts
Normal file
49
src/main/attachments/storage.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { app } from 'electron';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const FILENAME_MAX = 200;
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
const stripped = name
|
||||
.replace(/[\\/]/g, '_')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\x00-\x1f]/g, '')
|
||||
.replace(/^\.+/, '');
|
||||
return stripped.length > FILENAME_MAX ? stripped.slice(0, FILENAME_MAX) : stripped;
|
||||
}
|
||||
|
||||
export function attachmentsRoot(): string {
|
||||
return path.join(app.getPath('userData'), 'attachments');
|
||||
}
|
||||
|
||||
export function absolutePath(storedPath: string): string {
|
||||
return path.join(attachmentsRoot(), storedPath);
|
||||
}
|
||||
|
||||
export async function copyIntoTask(
|
||||
taskId: string,
|
||||
sourcePath: string,
|
||||
filename: string,
|
||||
): Promise<{ storedPath: string }> {
|
||||
const safeName = sanitizeFilename(filename);
|
||||
const dir = path.join(attachmentsRoot(), taskId);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const finalName = `${randomUUID()}-${safeName}`;
|
||||
const dest = path.join(dir, finalName);
|
||||
await fs.copyFile(sourcePath, dest);
|
||||
return { storedPath: path.join(taskId, finalName) };
|
||||
}
|
||||
|
||||
export async function deleteStored(storedPath: string): Promise<void> {
|
||||
const abs = absolutePath(storedPath);
|
||||
await fs.unlink(abs).catch((err) => {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteTaskDir(taskId: string): Promise<void> {
|
||||
const dir = path.join(attachmentsRoot(), taskId);
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||
import { app } from 'electron';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as schema from './schema';
|
||||
|
||||
@@ -10,147 +12,81 @@ let _dbPath: string | null = null;
|
||||
/** Raw better-sqlite3 instance (needed for .backup() API). */
|
||||
let _rawSqlite: Database.Database | null = null;
|
||||
|
||||
// SQL to create all tables if they don't exist (non-destructive push strategy)
|
||||
const MIGRATION_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
industry TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
client_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
ai_summary TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'todo',
|
||||
priority TEXT NOT NULL DEFAULT 'medium',
|
||||
assignee TEXT,
|
||||
due_date INTEGER,
|
||||
is_ai_suggested INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
completed_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
ai_summary TEXT,
|
||||
ai_summary_updated_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_edits (
|
||||
id TEXT PRIMARY KEY,
|
||||
note_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
anchor_before TEXT,
|
||||
anchor_text TEXT,
|
||||
proposed_content TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
agent_id TEXT,
|
||||
run_id TEXT,
|
||||
reasoning TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
resolved_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
author TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS timeline_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
date INTEGER NOT NULL,
|
||||
end_date INTEGER,
|
||||
type TEXT NOT NULL DEFAULT 'milestone',
|
||||
is_completed INTEGER NOT NULL DEFAULT 0,
|
||||
is_ai_suggested INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
completed_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS timeline_event_dependencies (
|
||||
id TEXT PRIMARY KEY,
|
||||
from_event_id TEXT NOT NULL,
|
||||
to_event_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_queue (
|
||||
id TEXT PRIMARY KEY,
|
||||
action TEXT NOT NULL,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
retries INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_attempt_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
agent_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
started_at INTEGER NOT NULL,
|
||||
completed_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_run_actions (
|
||||
id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
verb TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT,
|
||||
entity_title TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_briefings (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
briefing_markdown TEXT NOT NULL,
|
||||
canvas_draft TEXT,
|
||||
canvas_kind TEXT,
|
||||
citations TEXT,
|
||||
source_task_hash TEXT NOT NULL,
|
||||
generated_at INTEGER NOT NULL,
|
||||
model_version TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_brief_chats (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
is_error INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
type DbInstance = ReturnType<typeof drizzle<typeof schema>>;
|
||||
|
||||
let dbInstance: DbInstance | null = null;
|
||||
|
||||
/**
|
||||
* Resolve the migrations folder location.
|
||||
*
|
||||
* - Packaged: shipped via electron-forge `extraResource` → `<resourcesPath>/migrations`.
|
||||
* - Dev: lives in the source tree at `<appPath>/src/main/db/migrations`. We do NOT
|
||||
* resolve from `__dirname` because Vite bundles `src/main/**` into a single
|
||||
* `.vite/build/main.js` and the migrations folder is not copied next to it.
|
||||
*/
|
||||
function resolveMigrationsFolder(): string {
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, 'migrations');
|
||||
}
|
||||
return path.join(app.getAppPath(), 'src', 'main', 'db', 'migrations');
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time bootstrap for DBs created by the legacy hand-rolled MIGRATION_SQL.
|
||||
*
|
||||
* Pre-Drizzle-migrator era, schema was managed by ad-hoc CREATE TABLE IF NOT EXISTS
|
||||
* + try/catch ALTER TABLE. Those DBs have all the tables from migrations 0000-0003
|
||||
* but no `__drizzle_migrations` ledger. If we just call migrate(), it will try to
|
||||
* re-run 0000 and crash on duplicate table.
|
||||
*
|
||||
* Strategy: if the DB looks pre-existing (has a `tasks` table) but no migrations
|
||||
* ledger, create the ledger and mark all migrations EXCEPT the latest as applied.
|
||||
* The migrator will then only run the latest one (0004 — adds `estimate` column +
|
||||
* `task_attachments` table — both genuinely missing from legacy DBs).
|
||||
*/
|
||||
function bootstrapMigrationsLedger(sqlite: Database.Database, migrationsFolder: string): void {
|
||||
const hasLedger = sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'")
|
||||
.get();
|
||||
if (hasLedger) return;
|
||||
|
||||
const hasTasks = sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='tasks'")
|
||||
.get();
|
||||
if (!hasTasks) return; // fresh DB — let the migrator create everything from scratch
|
||||
|
||||
// Legacy DB detected. Build the ledger Drizzle expects.
|
||||
// Schema must match drizzle-orm/sqlite-core/dialect.js migrate():
|
||||
// id SERIAL PRIMARY KEY, hash text NOT NULL, created_at numeric
|
||||
sqlite.exec(`
|
||||
CREATE TABLE __drizzle_migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at numeric
|
||||
);
|
||||
`);
|
||||
|
||||
const journalPath = path.join(migrationsFolder, 'meta', '_journal.json');
|
||||
const journal = JSON.parse(fs.readFileSync(journalPath, 'utf8')) as {
|
||||
entries: { idx: number; tag: string; when: number }[];
|
||||
};
|
||||
|
||||
// Mark everything except the latest entry as applied.
|
||||
// Drizzle's migrator filters by `lastDbMigration.created_at < migration.folderMillis`,
|
||||
// so seeding the second-to-last entry's `when` is sufficient.
|
||||
const toMark = journal.entries.slice(0, -1);
|
||||
if (toMark.length === 0) return;
|
||||
|
||||
const insert = sqlite.prepare(
|
||||
'INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)',
|
||||
);
|
||||
for (const entry of toMark) {
|
||||
// Hash value is opaque to the migrator — only created_at matters for the cutoff.
|
||||
// Use the tag for traceability.
|
||||
insert.run(entry.tag, entry.when);
|
||||
}
|
||||
}
|
||||
|
||||
export function initDb(): DbInstance {
|
||||
const userDataPath = app.getPath('userData');
|
||||
_dbPath = path.join(userDataPath, 'adiuvai.db');
|
||||
@@ -162,23 +98,12 @@ export function initDb(): DbInstance {
|
||||
sqlite.pragma('journal_mode = WAL');
|
||||
sqlite.pragma('synchronous = NORMAL');
|
||||
|
||||
// Run non-destructive migrations on every start
|
||||
sqlite.exec(MIGRATION_SQL);
|
||||
|
||||
// Additive column migrations (SQLite has no ADD COLUMN IF NOT EXISTS)
|
||||
try { sqlite.exec('ALTER TABLE tasks ADD COLUMN is_ai_suggested INTEGER NOT NULL DEFAULT 0'); } catch { /* already exists */ }
|
||||
try { sqlite.exec('ALTER TABLE tasks DROP COLUMN is_approved'); } catch { /* already dropped or not supported */ }
|
||||
try {
|
||||
sqlite.exec("ALTER TABLE timeline_events ADD COLUMN type TEXT NOT NULL DEFAULT 'milestone'");
|
||||
// Backfill: existing multi-day events become activities, single-day stay as milestones
|
||||
sqlite.exec("UPDATE timeline_events SET type = 'activity' WHERE end_date IS NOT NULL AND type = 'milestone'");
|
||||
} catch { /* column already exists */ }
|
||||
try { sqlite.exec('ALTER TABLE tasks ADD COLUMN completed_at INTEGER'); } catch { /* already exists */ }
|
||||
try { sqlite.exec('ALTER TABLE timeline_events ADD COLUMN completed_at INTEGER'); } catch { /* already exists */ }
|
||||
try { sqlite.exec('ALTER TABLE notes ADD COLUMN ai_summary TEXT'); } catch { /* already exists */ }
|
||||
try { sqlite.exec('ALTER TABLE notes ADD COLUMN ai_summary_updated_at INTEGER'); } catch { /* already exists */ }
|
||||
const migrationsFolder = resolveMigrationsFolder();
|
||||
bootstrapMigrationsLedger(sqlite, migrationsFolder);
|
||||
|
||||
dbInstance = drizzle(sqlite, { schema });
|
||||
migrate(dbInstance, { migrationsFolder });
|
||||
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
|
||||
11
src/main/db/migrations/0004_right_alex_power.sql
Normal file
11
src/main/db/migrations/0004_right_alex_power.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE `task_attachments` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`task_id` text NOT NULL,
|
||||
`filename` text NOT NULL,
|
||||
`mime_type` text,
|
||||
`size_bytes` integer NOT NULL,
|
||||
`stored_path` text NOT NULL,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `tasks` ADD `estimate` integer;
|
||||
16
src/main/db/migrations/0005_slim_baron_strucker.sql
Normal file
16
src/main/db/migrations/0005_slim_baron_strucker.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE `project_folder_files` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`relative_path` text NOT NULL,
|
||||
`ext` text NOT NULL,
|
||||
`kind` text NOT NULL,
|
||||
`size_bytes` integer NOT NULL,
|
||||
`mtime_ms` integer NOT NULL,
|
||||
`summary` text,
|
||||
`summary_updated_at` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `folder_path` text;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `folder_last_scanned_at` integer;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `folder_last_scan_status` text DEFAULT 'idle';--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `folder_total_files` integer DEFAULT 0 NOT NULL;
|
||||
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
|
||||
);
|
||||
831
src/main/db/migrations/meta/0004_snapshot.json
Normal file
831
src/main/db/migrations/meta/0004_snapshot.json
Normal file
@@ -0,0 +1,831 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "8127cd67-44d0-41e8-a146-12eb1311c6c1",
|
||||
"prevId": "d42caef6-2cfa-48bf-a8b3-46de4af43f47",
|
||||
"tables": {
|
||||
"agent_run_actions": {
|
||||
"name": "agent_run_actions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verb": {
|
||||
"name": "verb",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_type": {
|
||||
"name": "entity_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "entity_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_title": {
|
||||
"name": "entity_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"agent_runs": {
|
||||
"name": "agent_runs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'running'"
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"clients": {
|
||||
"name": "clients",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"industry": {
|
||||
"name": "industry",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"note_edits": {
|
||||
"name": "note_edits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_before": {
|
||||
"name": "anchor_before",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_text": {
|
||||
"name": "anchor_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"proposed_content": {
|
||||
"name": "proposed_content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reasoning": {
|
||||
"name": "reasoning",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ai_summary_updated_at": {
|
||||
"name": "ai_summary_updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"projects": {
|
||||
"name": "projects",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_attachments": {
|
||||
"name": "task_attachments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"filename": {
|
||||
"name": "filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mime_type": {
|
||||
"name": "mime_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"size_bytes": {
|
||||
"name": "size_bytes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stored_path": {
|
||||
"name": "stored_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_brief_chats": {
|
||||
"name": "task_brief_chats",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_error": {
|
||||
"name": "is_error",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_briefings": {
|
||||
"name": "task_briefings",
|
||||
"columns": {
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"briefing_markdown": {
|
||||
"name": "briefing_markdown",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"canvas_draft": {
|
||||
"name": "canvas_draft",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"canvas_kind": {
|
||||
"name": "canvas_kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"citations": {
|
||||
"name": "citations",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source_task_hash": {
|
||||
"name": "source_task_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"generated_at": {
|
||||
"name": "generated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model_version": {
|
||||
"name": "model_version",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_comments": {
|
||||
"name": "task_comments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tasks": {
|
||||
"name": "tasks",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'todo'"
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'medium'"
|
||||
},
|
||||
"assignee": {
|
||||
"name": "assignee",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"due_date": {
|
||||
"name": "due_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"estimate": {
|
||||
"name": "estimate",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_event_dependencies": {
|
||||
"name": "timeline_event_dependencies",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"from_event_id": {
|
||||
"name": "from_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"to_event_id": {
|
||||
"name": "to_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_events": {
|
||||
"name": "timeline_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"end_date": {
|
||||
"name": "end_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'milestone'"
|
||||
},
|
||||
"is_completed": {
|
||||
"name": "is_completed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
934
src/main/db/migrations/meta/0005_snapshot.json
Normal file
934
src/main/db/migrations/meta/0005_snapshot.json
Normal file
@@ -0,0 +1,934 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "db432653-ac1d-40f4-b7eb-216d054ae191",
|
||||
"prevId": "8127cd67-44d0-41e8-a146-12eb1311c6c1",
|
||||
"tables": {
|
||||
"agent_run_actions": {
|
||||
"name": "agent_run_actions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verb": {
|
||||
"name": "verb",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_type": {
|
||||
"name": "entity_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "entity_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_title": {
|
||||
"name": "entity_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"agent_runs": {
|
||||
"name": "agent_runs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'running'"
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"clients": {
|
||||
"name": "clients",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"industry": {
|
||||
"name": "industry",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"note_edits": {
|
||||
"name": "note_edits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_before": {
|
||||
"name": "anchor_before",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_text": {
|
||||
"name": "anchor_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"proposed_content": {
|
||||
"name": "proposed_content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reasoning": {
|
||||
"name": "reasoning",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ai_summary_updated_at": {
|
||||
"name": "ai_summary_updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"project_folder_files": {
|
||||
"name": "project_folder_files",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"relative_path": {
|
||||
"name": "relative_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ext": {
|
||||
"name": "ext",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"size_bytes": {
|
||||
"name": "size_bytes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mtime_ms": {
|
||||
"name": "mtime_ms",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"summary": {
|
||||
"name": "summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"summary_updated_at": {
|
||||
"name": "summary_updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"projects": {
|
||||
"name": "projects",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"folder_path": {
|
||||
"name": "folder_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"folder_last_scanned_at": {
|
||||
"name": "folder_last_scanned_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"folder_last_scan_status": {
|
||||
"name": "folder_last_scan_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'idle'"
|
||||
},
|
||||
"folder_total_files": {
|
||||
"name": "folder_total_files",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_attachments": {
|
||||
"name": "task_attachments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"filename": {
|
||||
"name": "filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mime_type": {
|
||||
"name": "mime_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"size_bytes": {
|
||||
"name": "size_bytes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stored_path": {
|
||||
"name": "stored_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_brief_chats": {
|
||||
"name": "task_brief_chats",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_error": {
|
||||
"name": "is_error",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_briefings": {
|
||||
"name": "task_briefings",
|
||||
"columns": {
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"briefing_markdown": {
|
||||
"name": "briefing_markdown",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"canvas_draft": {
|
||||
"name": "canvas_draft",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"canvas_kind": {
|
||||
"name": "canvas_kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"citations": {
|
||||
"name": "citations",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source_task_hash": {
|
||||
"name": "source_task_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"generated_at": {
|
||||
"name": "generated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model_version": {
|
||||
"name": "model_version",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_comments": {
|
||||
"name": "task_comments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tasks": {
|
||||
"name": "tasks",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'todo'"
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'medium'"
|
||||
},
|
||||
"assignee": {
|
||||
"name": "assignee",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"due_date": {
|
||||
"name": "due_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"estimate": {
|
||||
"name": "estimate",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_event_dependencies": {
|
||||
"name": "timeline_event_dependencies",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"from_event_id": {
|
||||
"name": "from_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"to_event_id": {
|
||||
"name": "to_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_events": {
|
||||
"name": "timeline_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"end_date": {
|
||||
"name": "end_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'milestone'"
|
||||
},
|
||||
"is_completed": {
|
||||
"name": "is_completed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
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
@@ -29,6 +29,41 @@
|
||||
"when": 1777889091889,
|
||||
"tag": "0003_shiny_karma",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1778238659431,
|
||||
"tag": "0004_right_alex_power",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1778579196669,
|
||||
"tag": "0005_slim_baron_strucker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1778777130582,
|
||||
"tag": "0006_misty_cammi",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1778777200000,
|
||||
"tag": "0007_scouts_rename",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1778777300000,
|
||||
"tag": "0008_scout_suggestions",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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() ?? '';
|
||||
|
||||
@@ -16,6 +16,12 @@ export const projects = sqliteTable('projects', {
|
||||
status: text('status', { enum: ['active', 'archived'] }).notNull().default('active'),
|
||||
aiSummary: text('ai_summary'),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
folderPath: text('folder_path'),
|
||||
folderLastScannedAt: integer('folder_last_scanned_at', { mode: 'number' }),
|
||||
folderLastScanStatus: text('folder_last_scan_status', {
|
||||
enum: ['idle', 'scanning', 'error'],
|
||||
}).default('idle'),
|
||||
folderTotalFiles: integer('folder_total_files', { mode: 'number' }).notNull().default(0),
|
||||
});
|
||||
|
||||
export const tasks = sqliteTable('tasks', {
|
||||
@@ -27,6 +33,7 @@ export const tasks = sqliteTable('tasks', {
|
||||
priority: text('priority').notNull().default('medium'),
|
||||
assignee: text('assignee'),
|
||||
dueDate: integer('due_date', { mode: 'number' }),
|
||||
estimate: integer('estimate', { mode: 'number' }),
|
||||
isAiSuggested: integer('is_ai_suggested', { mode: 'number' }).notNull().default(0),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
completedAt: integer('completed_at', { mode: 'number' }),
|
||||
@@ -63,6 +70,21 @@ export const notes = sqliteTable('notes', {
|
||||
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
export const projectFolderFiles = sqliteTable('project_folder_files', {
|
||||
id: text('id').primaryKey(),
|
||||
projectId: text('project_id').notNull(),
|
||||
relativePath: text('relative_path').notNull(),
|
||||
ext: text('ext').notNull(),
|
||||
kind: text('kind', { enum: ['text', 'image', 'pdf', 'docx', 'csv', 'skipped', 'error'] }).notNull(),
|
||||
sizeBytes: integer('size_bytes', { mode: 'number' }).notNull(),
|
||||
mtimeMs: integer('mtime_ms', { mode: 'number' }).notNull(),
|
||||
summary: text('summary'),
|
||||
summaryUpdatedAt: integer('summary_updated_at', { mode: 'number' }),
|
||||
});
|
||||
|
||||
export type ProjectFolderFile = InferSelectModel<typeof projectFolderFiles>;
|
||||
export type NewProjectFolderFile = InferInsertModel<typeof projectFolderFiles>;
|
||||
|
||||
export const noteEdits = sqliteTable('note_edits', {
|
||||
id: text('id').primaryKey(),
|
||||
noteId: text('note_id').notNull(),
|
||||
@@ -86,6 +108,16 @@ export const taskComments = sqliteTable('task_comments', {
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
export const taskAttachments = sqliteTable('task_attachments', {
|
||||
id: text('id').primaryKey(),
|
||||
taskId: text('task_id').notNull(),
|
||||
filename: text('filename').notNull(),
|
||||
mimeType: text('mime_type'),
|
||||
sizeBytes: integer('size_bytes', { mode: 'number' }).notNull(),
|
||||
storedPath: text('stored_path').notNull(),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
// Inferred TypeScript types — no manual duplication
|
||||
export type Client = InferSelectModel<typeof clients>;
|
||||
export type NewClient = InferInsertModel<typeof clients>;
|
||||
@@ -102,6 +134,9 @@ export type NewNote = InferInsertModel<typeof notes>;
|
||||
export type TaskComment = InferSelectModel<typeof taskComments>;
|
||||
export type NewTaskComment = InferInsertModel<typeof taskComments>;
|
||||
|
||||
export type TaskAttachment = InferSelectModel<typeof taskAttachments>;
|
||||
export type NewTaskAttachment = InferInsertModel<typeof taskAttachments>;
|
||||
|
||||
export type TimelineEvent = InferSelectModel<typeof timelineEvents>;
|
||||
export type NewTimelineEvent = InferInsertModel<typeof timelineEvents>;
|
||||
|
||||
@@ -134,18 +169,18 @@ export const taskBriefChats = sqliteTable('task_brief_chats', {
|
||||
export type TaskBriefChat = InferSelectModel<typeof taskBriefChats>;
|
||||
export type NewTaskBriefChat = InferInsertModel<typeof taskBriefChats>;
|
||||
|
||||
export const agentRuns = sqliteTable('agent_runs', {
|
||||
export const scoutRuns = sqliteTable('scout_runs', {
|
||||
id: text('id').primaryKey(),
|
||||
agentId: text('agent_id').notNull(),
|
||||
scoutId: text('scout_id').notNull(),
|
||||
status: text('status', { enum: ['running', 'completed', 'failed', 'partial'] }).notNull().default('running'),
|
||||
startedAt: integer('started_at', { mode: 'number' }).notNull(),
|
||||
completedAt: integer('completed_at', { mode: 'number' }),
|
||||
});
|
||||
|
||||
export const agentRunActions = sqliteTable('agent_run_actions', {
|
||||
export const scoutRunActions = sqliteTable('scout_run_actions', {
|
||||
id: text('id').primaryKey(),
|
||||
runId: text('run_id').notNull(),
|
||||
agentId: text('agent_id').notNull(),
|
||||
scoutId: text('scout_id').notNull(),
|
||||
/** 'created' | 'updated' | 'deleted' | 'commented' */
|
||||
verb: text('verb').notNull(),
|
||||
/** 'task' | 'note' | 'project' | 'timeline' | 'comment' */
|
||||
@@ -155,10 +190,54 @@ export const agentRunActions = sqliteTable('agent_run_actions', {
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
export type AgentRun = InferSelectModel<typeof agentRuns>;
|
||||
export type NewAgentRun = InferInsertModel<typeof agentRuns>;
|
||||
export type AgentRunAction = InferSelectModel<typeof agentRunActions>;
|
||||
export type NewAgentRunAction = InferInsertModel<typeof agentRunActions>;
|
||||
export type ScoutRun = InferSelectModel<typeof scoutRuns>;
|
||||
export type NewScoutRun = InferInsertModel<typeof scoutRuns>;
|
||||
export type ScoutRunAction = InferSelectModel<typeof scoutRunActions>;
|
||||
export type NewScoutRunAction = InferInsertModel<typeof scoutRunActions>;
|
||||
|
||||
export type NoteEdit = InferSelectModel<typeof noteEdits>;
|
||||
export type NewNoteEdit = InferInsertModel<typeof noteEdits>;
|
||||
|
||||
export const aiChatSessions = sqliteTable('ai_chat_sessions', {
|
||||
id: text('id').primaryKey(),
|
||||
channel: text('channel', { enum: ['home', 'contextual'] }).notNull(),
|
||||
title: text('title'),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
|
||||
lastScope: text('last_scope'),
|
||||
});
|
||||
|
||||
export const aiChatMessages = sqliteTable('ai_chat_messages', {
|
||||
id: text('id').primaryKey(),
|
||||
sessionId: text('session_id').notNull(),
|
||||
role: text('role', { enum: ['user', 'assistant', 'system'] }).notNull(),
|
||||
content: text('content').notNull(),
|
||||
toolCalls: text('tool_calls'),
|
||||
toolResults: text('tool_results'),
|
||||
scope: text('scope'),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
export type AiChatSession = InferSelectModel<typeof aiChatSessions>;
|
||||
export type NewAiChatSession = InferInsertModel<typeof aiChatSessions>;
|
||||
export type AiChatMessage = InferSelectModel<typeof aiChatMessages>;
|
||||
export type NewAiChatMessage = InferInsertModel<typeof aiChatMessages>;
|
||||
|
||||
export const scoutSuggestions = sqliteTable('scout_suggestions', {
|
||||
id: text().primaryKey(),
|
||||
scoutId: text('scout_id').notNull(),
|
||||
sourceType: text('source_type').notNull(),
|
||||
sourceMsgRef: text('source_msg_ref').notNull(),
|
||||
category: text().notNull(), // "unprocessed" until Phase 4
|
||||
payload: text(), // JSON, populated by Phase 4
|
||||
rawSubject: text('raw_subject'),
|
||||
rawSnippet: text('raw_snippet'),
|
||||
status: text().notNull(), // pending | approved | rejected | expired
|
||||
proposedAt: integer('proposed_at').notNull(),
|
||||
resolvedAt: integer('resolved_at'),
|
||||
resolvedEntityType: text('resolved_entity_type'),
|
||||
resolvedEntityId: text('resolved_entity_id'),
|
||||
});
|
||||
|
||||
export type ScoutSuggestion = InferSelectModel<typeof scoutSuggestions>;
|
||||
export type NewScoutSuggestion = InferInsertModel<typeof scoutSuggestions>;
|
||||
|
||||
21
src/main/files/constants.ts
Normal file
21
src/main/files/constants.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/** File-type whitelists & size caps for project folder indexing. */
|
||||
|
||||
export const TEXT_EXTS = new Set([
|
||||
'.md', '.txt', '.rst', '.adoc',
|
||||
'.json', '.yaml', '.yml', '.toml', '.ini', '.csv', '.tsv',
|
||||
'.html', '.htm', '.xml',
|
||||
'.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs',
|
||||
'.py', '.rb', '.go', '.rs', '.java', '.kt', '.swift',
|
||||
'.c', '.h', '.cpp', '.hpp', '.cs', '.php', '.sh', '.ps1',
|
||||
'.css', '.scss', '.sass',
|
||||
'.sql',
|
||||
]);
|
||||
|
||||
export const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
||||
|
||||
export const PDF_EXTS = new Set(['.pdf']);
|
||||
export const DOCX_EXTS = new Set(['.docx']);
|
||||
|
||||
export const MAX_TEXT_FILE_BYTES = 1 * 1024 * 1024; // 1 MB
|
||||
export const MAX_IMAGE_FILE_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||
export const INDEX_BATCH_SIZE = 5;
|
||||
27
src/main/files/daily-rescan.ts
Normal file
27
src/main/files/daily-rescan.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// adiuvAI/src/main/files/daily-rescan.ts
|
||||
import { getDb } from '../db';
|
||||
import { projects } from '../db/schema';
|
||||
import { sql, and, isNotNull } from 'drizzle-orm';
|
||||
import { startIndexSession } from './indexer';
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export async function runDailyRescan(): Promise<void> {
|
||||
const cutoff = Date.now() - ONE_DAY_MS;
|
||||
const stale = getDb()
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(
|
||||
and(
|
||||
isNotNull(projects.folderPath),
|
||||
sql`(${projects.folderLastScannedAt} IS NULL OR ${projects.folderLastScannedAt} < ${cutoff})`,
|
||||
),
|
||||
)
|
||||
.all();
|
||||
for (const p of stale) {
|
||||
if (p.folderLastScanStatus === 'scanning') continue;
|
||||
// Fire-and-forget; no UI listener.
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
void startIndexSession(p.id, () => {});
|
||||
}
|
||||
}
|
||||
222
src/main/files/indexer.ts
Normal file
222
src/main/files/indexer.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Folder index session orchestrator.
|
||||
*
|
||||
* Walks a folder via scanner.ts, sends batches over WS to the backend, applies
|
||||
* returned summaries to projectFolderFiles, drives progress callbacks.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getDb } from '../db';
|
||||
import { projects, projectFolderFiles } from '../db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { scanFolder, type ScannedFile } from './scanner';
|
||||
import { INDEX_BATCH_SIZE } from './constants';
|
||||
import { getBackendClient } from '../api/backend-client';
|
||||
|
||||
export interface IndexProgress {
|
||||
sessionId: string;
|
||||
processed: number;
|
||||
total: number;
|
||||
status: 'starting' | 'scanning' | 'cancelled' | 'completed' | 'quota_exceeded' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type ProgressListener = (p: IndexProgress) => void;
|
||||
|
||||
async function readForIndex(
|
||||
folderPath: string,
|
||||
f: ScannedFile,
|
||||
): Promise<{ content: string; mime?: string }> {
|
||||
const abs = path.join(folderPath, f.relativePath);
|
||||
if (f.kind === 'image') {
|
||||
const buf = await readFile(abs);
|
||||
const ext = f.ext.toLowerCase();
|
||||
const mime =
|
||||
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
|
||||
return { content: buf.toString('base64'), mime };
|
||||
}
|
||||
if (f.kind === 'text') {
|
||||
return { content: await readFile(abs, 'utf-8') };
|
||||
}
|
||||
// pdf / docx: read as binary, base64. Server is responsible for extraction.
|
||||
const buf = await readFile(abs);
|
||||
return { content: buf.toString('base64') };
|
||||
}
|
||||
|
||||
export async function startIndexSession(
|
||||
projectId: string,
|
||||
onProgress: ProgressListener,
|
||||
): Promise<{ sessionId: string; cancel: () => void }> {
|
||||
const sessionId = randomUUID();
|
||||
const db = getDb();
|
||||
|
||||
const proj = db.select().from(projects).where(eq(projects.id, projectId)).get();
|
||||
if (!proj || !proj.folderPath) {
|
||||
onProgress({ sessionId, processed: 0, total: 0, status: 'error', error: 'No folder linked' });
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return { sessionId, cancel: () => {} };
|
||||
}
|
||||
|
||||
db.update(projects)
|
||||
.set({ folderLastScanStatus: 'scanning' })
|
||||
.where(eq(projects.id, projectId))
|
||||
.run();
|
||||
onProgress({ sessionId, processed: 0, total: 0, status: 'scanning' });
|
||||
|
||||
const delta = await scanFolder(projectId, proj.folderPath);
|
||||
|
||||
// Filter out 'skipped' files — they are too large to index and must not be sent
|
||||
const toIndex = [
|
||||
...delta.newFiles.filter((f) => f.kind !== 'skipped'),
|
||||
...delta.changedFiles.filter((f) => f.kind !== 'skipped'),
|
||||
];
|
||||
const total = toIndex.length;
|
||||
|
||||
for (const rel of delta.deletedRelPaths) {
|
||||
db.delete(projectFolderFiles)
|
||||
.where(
|
||||
and(
|
||||
eq(projectFolderFiles.projectId, projectId),
|
||||
eq(projectFolderFiles.relativePath, rel),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
db.update(projects)
|
||||
.set({
|
||||
folderLastScanStatus: 'idle',
|
||||
folderLastScannedAt: Date.now(),
|
||||
folderTotalFiles: delta.unchangedCount,
|
||||
})
|
||||
.where(eq(projects.id, projectId))
|
||||
.run();
|
||||
onProgress({ sessionId, processed: 0, total: 0, status: 'completed' });
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return { sessionId, cancel: () => {} };
|
||||
}
|
||||
|
||||
const backend = getBackendClient();
|
||||
|
||||
let processed = 0;
|
||||
let cancelled = false;
|
||||
|
||||
const finalize = (status: IndexProgress['status'], error?: string): void => {
|
||||
db.update(projects)
|
||||
.set({
|
||||
folderLastScanStatus:
|
||||
status === 'completed' || status === 'cancelled' ? 'idle' : 'error',
|
||||
folderLastScannedAt: Date.now(),
|
||||
folderTotalFiles: delta.unchangedCount + processed,
|
||||
})
|
||||
.where(eq(projects.id, projectId))
|
||||
.run();
|
||||
onProgress({ sessionId, processed, total, status, error });
|
||||
};
|
||||
|
||||
backend.registerIndexSession(sessionId, {
|
||||
onFileResult: ({ relPath, summary, error }) => {
|
||||
if (error) return;
|
||||
const f = toIndex.find((x) => x.relativePath === relPath);
|
||||
if (!f) return;
|
||||
const now = Date.now();
|
||||
|
||||
// SELECT-then-INSERT-or-UPDATE: no unique index on (projectId, relativePath)
|
||||
const existing = db
|
||||
.select()
|
||||
.from(projectFolderFiles)
|
||||
.where(
|
||||
and(
|
||||
eq(projectFolderFiles.projectId, projectId),
|
||||
eq(projectFolderFiles.relativePath, f.relativePath),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (existing) {
|
||||
db.update(projectFolderFiles)
|
||||
.set({
|
||||
mtimeMs: f.mtimeMs,
|
||||
sizeBytes: f.sizeBytes,
|
||||
kind: f.kind,
|
||||
summary: summary ?? null,
|
||||
summaryUpdatedAt: now,
|
||||
})
|
||||
.where(eq(projectFolderFiles.id, existing.id))
|
||||
.run();
|
||||
} else {
|
||||
db.insert(projectFolderFiles)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
projectId,
|
||||
relativePath: f.relativePath,
|
||||
ext: f.ext,
|
||||
kind: f.kind,
|
||||
sizeBytes: f.sizeBytes,
|
||||
mtimeMs: f.mtimeMs,
|
||||
summary: summary ?? null,
|
||||
summaryUpdatedAt: now,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
},
|
||||
onProgress: ({ processed: p, total: t }) => {
|
||||
processed = p;
|
||||
onProgress({ sessionId, processed: p, total: t, status: 'scanning' });
|
||||
},
|
||||
onDone: (status) => {
|
||||
finalize(
|
||||
status === 'completed'
|
||||
? 'completed'
|
||||
: status === 'cancelled'
|
||||
? 'cancelled'
|
||||
: status === 'quota_exceeded'
|
||||
? 'quota_exceeded'
|
||||
: 'error',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
backend.sendIndexSessionStart(sessionId, projectId, total);
|
||||
} catch (err) {
|
||||
finalize('error', err instanceof Error ? err.message : 'WS send failed');
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return { sessionId, cancel: () => {} };
|
||||
}
|
||||
|
||||
// Send batches (skipped files already excluded from toIndex)
|
||||
for (let i = 0; i < toIndex.length; i += INDEX_BATCH_SIZE) {
|
||||
if (cancelled) break;
|
||||
const batch = toIndex.slice(i, i + INDEX_BATCH_SIZE);
|
||||
const payload = await Promise.all(
|
||||
batch.map(async (f) => {
|
||||
const { content, mime } = await readForIndex(proj.folderPath!, f);
|
||||
return {
|
||||
relPath: f.relativePath,
|
||||
kind: f.kind as 'text' | 'image' | 'pdf' | 'docx',
|
||||
content,
|
||||
ext: f.ext,
|
||||
mime,
|
||||
sizeBytes: f.sizeBytes,
|
||||
mtimeMs: f.mtimeMs,
|
||||
};
|
||||
}),
|
||||
);
|
||||
try {
|
||||
backend.sendIndexFileBatch(sessionId, payload);
|
||||
} catch (err) {
|
||||
finalize('error', err instanceof Error ? err.message : 'WS send failed');
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return { sessionId, cancel: () => {} };
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = (): void => {
|
||||
cancelled = true;
|
||||
backend.sendIndexSessionCancel(sessionId);
|
||||
};
|
||||
return { sessionId, cancel };
|
||||
}
|
||||
95
src/main/files/scanner.ts
Normal file
95
src/main/files/scanner.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/** Filesystem scanner — walks a directory, filters by whitelist, computes delta vs DB manifest. */
|
||||
|
||||
import { readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { getDb } from '../db';
|
||||
import { projectFolderFiles } from '../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import {
|
||||
TEXT_EXTS, IMAGE_EXTS, PDF_EXTS, DOCX_EXTS,
|
||||
MAX_TEXT_FILE_BYTES, MAX_IMAGE_FILE_BYTES,
|
||||
} from './constants';
|
||||
|
||||
export type FileKind = 'text' | 'image' | 'pdf' | 'docx' | 'skipped';
|
||||
|
||||
export interface ScannedFile {
|
||||
relativePath: string;
|
||||
ext: string;
|
||||
kind: FileKind;
|
||||
sizeBytes: number;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
export interface ScanDelta {
|
||||
newFiles: ScannedFile[];
|
||||
changedFiles: ScannedFile[];
|
||||
unchangedCount: number;
|
||||
deletedRelPaths: string[];
|
||||
}
|
||||
|
||||
function classify(ext: string, sizeBytes: number): FileKind | null {
|
||||
const e = ext.toLowerCase();
|
||||
if (TEXT_EXTS.has(e)) return sizeBytes <= MAX_TEXT_FILE_BYTES ? 'text' : 'skipped';
|
||||
if (IMAGE_EXTS.has(e)) return sizeBytes <= MAX_IMAGE_FILE_BYTES ? 'image' : 'skipped';
|
||||
if (PDF_EXTS.has(e)) return sizeBytes <= MAX_TEXT_FILE_BYTES ? 'pdf' : 'skipped';
|
||||
if (DOCX_EXTS.has(e)) return sizeBytes <= MAX_TEXT_FILE_BYTES ? 'docx' : 'skipped';
|
||||
return null; // not indexable
|
||||
}
|
||||
|
||||
async function walk(root: string): Promise<ScannedFile[]> {
|
||||
const out: ScannedFile[] = [];
|
||||
async function recurse(dir: string) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return; // permission denied — skip silently
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (e.name.startsWith('.')) continue; // skip dot dirs / files
|
||||
if (e.name === 'node_modules') continue; // common noise
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
await recurse(full);
|
||||
} else if (e.isFile()) {
|
||||
let s;
|
||||
try { s = await stat(full); } catch { continue; }
|
||||
const ext = path.extname(e.name);
|
||||
const kind = classify(ext, s.size);
|
||||
if (kind === null) continue;
|
||||
out.push({
|
||||
relativePath: path.relative(root, full),
|
||||
ext,
|
||||
kind,
|
||||
sizeBytes: s.size,
|
||||
mtimeMs: Math.floor(s.mtimeMs),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
await recurse(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function scanFolder(projectId: string, folderPath: string): Promise<ScanDelta> {
|
||||
const scanned = await walk(folderPath);
|
||||
const existing = getDb()
|
||||
.select()
|
||||
.from(projectFolderFiles)
|
||||
.where(eq(projectFolderFiles.projectId, projectId))
|
||||
.all();
|
||||
|
||||
const existingMap = new Map(existing.map(r => [r.relativePath, r]));
|
||||
const newFiles: ScannedFile[] = [];
|
||||
const changedFiles: ScannedFile[] = [];
|
||||
let unchanged = 0;
|
||||
for (const f of scanned) {
|
||||
const prev = existingMap.get(f.relativePath);
|
||||
if (!prev) newFiles.push(f);
|
||||
else if (prev.mtimeMs !== f.mtimeMs || prev.sizeBytes !== f.sizeBytes) changedFiles.push(f);
|
||||
else unchanged++;
|
||||
existingMap.delete(f.relativePath);
|
||||
}
|
||||
const deletedRelPaths = Array.from(existingMap.keys());
|
||||
return { newFiles, changedFiles, unchangedCount: unchanged, deletedRelPaths };
|
||||
}
|
||||
@@ -8,8 +8,9 @@ import { getAuthManager } from './auth/auth-manager';
|
||||
import { getBackendClient } from './api/backend-client';
|
||||
import { getStore } from './store';
|
||||
import { startBriefScheduler, stopBriefScheduler } from './ai/orchestrator';
|
||||
import { startAgentScheduler, stopAgentScheduler } from './agents/agent-scheduler';
|
||||
import { startScoutScheduler, stopScoutScheduler } from './scouts/scout-scheduler';
|
||||
import { backfillNoteSummaries } from './db/notes-backfill';
|
||||
import { runDailyRescan } from './files/daily-rescan';
|
||||
|
||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||
if (started) {
|
||||
@@ -33,10 +34,23 @@ if (process.defaultApp) {
|
||||
/**
|
||||
* Extract and dispatch an adiuvai:// deep link URL.
|
||||
* Delegates to AuthManager so the pending OAuth promise is resolved.
|
||||
* Also handles scout-specific OAuth callbacks (e.g. Gmail connector setup).
|
||||
*/
|
||||
function handleDeepLink(url: string): void {
|
||||
if (url.startsWith('adiuvai://oauth/callback')) {
|
||||
void getAuthManager().handleOAuthCallback(url);
|
||||
return;
|
||||
}
|
||||
|
||||
// Scout Gmail OAuth callback: adiuvai://scout/oauth/gmail/callback?code=...&state=...
|
||||
if (url.startsWith('adiuvai://scout/oauth/gmail/callback')) {
|
||||
const parsed = new URL(url);
|
||||
const code = parsed.searchParams.get('code');
|
||||
const state = parsed.searchParams.get('state');
|
||||
if (code && state) {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
windows[0]?.webContents.send('scout:gmailOAuthCallback', { code, state });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +123,13 @@ ipcMain.handle('dialog:showOpenDialog', (_event, options: Electron.OpenDialogOpt
|
||||
dialog.showOpenDialog(options),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contextual sidebar — scope update IPC handler (M4.7)
|
||||
// ---------------------------------------------------------------------------
|
||||
ipcMain.handle('ai:contextual-scope-update', (_event, args: { sessionId: string; scope: unknown }) => {
|
||||
getBackendClient().sendContextualScopeUpdate(args);
|
||||
});
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
@@ -133,13 +154,15 @@ app.on('ready', () => {
|
||||
.catch((err) => console.error('[DeviceWS] Startup connect failed:', err));
|
||||
|
||||
startBriefScheduler();
|
||||
startAgentScheduler();
|
||||
startScoutScheduler();
|
||||
// Delay so WS connection is likely up before triggering rescans
|
||||
setTimeout(() => { void runDailyRescan(); }, 10_000);
|
||||
});
|
||||
|
||||
// Clean up the persistent WS and backup timers before the app exits
|
||||
app.on('will-quit', () => {
|
||||
stopBriefScheduler();
|
||||
stopAgentScheduler();
|
||||
stopScoutScheduler();
|
||||
getBackendClient().disconnectPersistent();
|
||||
});
|
||||
|
||||
|
||||
105
src/main/router/ai-chat.ts
Normal file
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 };
|
||||
}),
|
||||
});
|
||||
@@ -2,17 +2,23 @@ import { initTRPC } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { eq, asc, desc, inArray, and, or, like, sql, gte, lte } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/sqlite-core';
|
||||
import { dialog, shell } from 'electron';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { getDb } from '../db';
|
||||
import { clients, projects, tasks, timelineEvents, timelineEventDependencies, notes, noteEdits, taskComments, agentRuns, agentRunActions, taskBriefings, taskBriefChats } from '../db/schema';
|
||||
import { clients, projects, tasks, timelineEvents, timelineEventDependencies, notes, noteEdits, taskComments, taskAttachments, scoutRuns, scoutRunActions, taskBriefings, taskBriefChats } from '../db/schema';
|
||||
import { copyIntoTask, deleteStored, absolutePath, deleteTaskDir } from '../attachments/storage';
|
||||
import { createHash } from 'crypto';
|
||||
import { getStore, getDeviceId, getLocalAgents, getLocalAgent, saveLocalAgent, deleteLocalAgent, getFormatPrefs, setFormatPrefs, getUiLanguage, setUiLanguage, getTimelineZoom, setTimelineZoom } from '../store';
|
||||
import type { LocalAgentLocalConfig } from '../store';
|
||||
import { getStore, getDeviceId, getLocalScouts, getLocalScout, saveLocalScout, deleteLocalScout, getFormatPrefs, setFormatPrefs, getUiLanguage, setUiLanguage, getTimelineZoom, setTimelineZoom } from '../store';
|
||||
import type { LocalScoutConfig } from '../store';
|
||||
import { getBackendClient } from '../api/backend-client';
|
||||
import type { AgentCatalogItem, CloudAgentConfig, AgentRunLog } from '../../shared/api-types';
|
||||
import { orchestrate, orchestrateFloating, orchestrateTaskBriefResearch, dailyBrief, getCachedBrief, invalidateBriefCache } from '../ai/orchestrator';
|
||||
import type { AgentCatalogItem, CloudScoutConfig, AgentRunLog } from '../../shared/api-types';
|
||||
import { orchestrate, orchestrateContextual, orchestrateTaskBriefResearch, dailyBrief, getCachedBrief, invalidateBriefCache } from '../ai/orchestrator';
|
||||
import { getAuthManager, AuthError } from '../auth/auth-manager';
|
||||
import { detectFormatPrefs, detectLanguage } from '../auth/locale-defaults';
|
||||
import type { TRPCContext } from '../ipc';
|
||||
import { projectFoldersRouter } from './projectFolders';
|
||||
import { aiChatRouter } from './ai-chat';
|
||||
|
||||
const t = initTRPC.context<TRPCContext>().create();
|
||||
|
||||
@@ -290,6 +296,7 @@ const tasksRouter = router({
|
||||
priority: tasks.priority,
|
||||
assignee: tasks.assignee,
|
||||
dueDate: tasks.dueDate,
|
||||
estimate: tasks.estimate,
|
||||
isAiSuggested: tasks.isAiSuggested,
|
||||
createdAt: tasks.createdAt,
|
||||
completedAt: tasks.completedAt,
|
||||
@@ -391,6 +398,7 @@ const tasksRouter = router({
|
||||
priority: tasks.priority,
|
||||
assignee: tasks.assignee,
|
||||
dueDate: tasks.dueDate,
|
||||
estimate: tasks.estimate,
|
||||
isAiSuggested: tasks.isAiSuggested,
|
||||
createdAt: tasks.createdAt,
|
||||
completedAt: tasks.completedAt,
|
||||
@@ -414,6 +422,7 @@ const tasksRouter = router({
|
||||
priority: z.string().optional(),
|
||||
assignees: z.array(z.string()).optional(),
|
||||
dueDate: z.number().optional(),
|
||||
estimate: z.number().int().nullable().optional(),
|
||||
projectId: z.string().optional(),
|
||||
isAiSuggested: z.number().optional(),
|
||||
}))
|
||||
@@ -429,6 +438,7 @@ const tasksRouter = router({
|
||||
priority: input.priority ?? 'medium',
|
||||
assignee: input.assignees?.length ? JSON.stringify(input.assignees) : null,
|
||||
dueDate: input.dueDate ?? null,
|
||||
estimate: input.estimate ?? null,
|
||||
projectId: input.projectId ?? null,
|
||||
isAiSuggested: input.isAiSuggested ?? 0,
|
||||
createdAt: now,
|
||||
@@ -447,6 +457,7 @@ const tasksRouter = router({
|
||||
priority: z.string().optional(),
|
||||
assignees: z.array(z.string()).optional(),
|
||||
dueDate: z.number().optional(),
|
||||
estimate: z.number().int().nullable().optional(),
|
||||
projectId: z.string().optional(),
|
||||
}))
|
||||
.mutation(({ input }) => {
|
||||
@@ -464,6 +475,7 @@ const tasksRouter = router({
|
||||
priority: string;
|
||||
assignee: string | null;
|
||||
dueDate: number | null;
|
||||
estimate: number | null;
|
||||
projectId: string | null;
|
||||
completedAt: number | null;
|
||||
}> = {};
|
||||
@@ -472,6 +484,7 @@ const tasksRouter = router({
|
||||
if (input.priority !== undefined) set.priority = input.priority;
|
||||
if (input.assignees !== undefined) set.assignee = input.assignees.length ? JSON.stringify(input.assignees) : null;
|
||||
if (input.dueDate !== undefined) set.dueDate = input.dueDate;
|
||||
if (input.estimate !== undefined) set.estimate = input.estimate;
|
||||
if (input.projectId !== undefined) set.projectId = input.projectId;
|
||||
if (input.status !== undefined) {
|
||||
set.status = input.status;
|
||||
@@ -497,16 +510,25 @@ const tasksRouter = router({
|
||||
|
||||
delete: publicProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const prev = getDb()
|
||||
.mutation(async ({ input }) => {
|
||||
const db = getDb();
|
||||
const prev = db
|
||||
.select({ dueDate: tasks.dueDate, status: tasks.status, completedAt: tasks.completedAt })
|
||||
.from(tasks)
|
||||
.where(eq(tasks.id, input.id))
|
||||
.all()[0];
|
||||
|
||||
getDb().delete(tasks).where(eq(tasks.id, input.id)).run();
|
||||
getDb().delete(taskBriefings).where(eq(taskBriefings.taskId, input.id)).run();
|
||||
getDb().delete(taskBriefChats).where(eq(taskBriefChats.taskId, input.id)).run();
|
||||
// Cascade attachments: unlink files, delete rows, remove task directory
|
||||
const atts = db.select().from(taskAttachments).where(eq(taskAttachments.taskId, input.id)).all();
|
||||
for (const a of atts) {
|
||||
await deleteStored(a.storedPath);
|
||||
}
|
||||
db.delete(taskAttachments).where(eq(taskAttachments.taskId, input.id)).run();
|
||||
await deleteTaskDir(input.id);
|
||||
|
||||
db.delete(tasks).where(eq(tasks.id, input.id)).run();
|
||||
db.delete(taskBriefings).where(eq(taskBriefings.taskId, input.id)).run();
|
||||
db.delete(taskBriefChats).where(eq(taskBriefChats.taskId, input.id)).run();
|
||||
if (isBriefRelevantTask(prev?.dueDate, prev?.status, prev?.completedAt)) invalidateBriefCache();
|
||||
return { success: true as const };
|
||||
}),
|
||||
@@ -908,25 +930,20 @@ const aiRouter = router({
|
||||
content: z.string(),
|
||||
})).optional(),
|
||||
sessionId: z.string().optional(),
|
||||
mode: z.enum(['home', 'floating']).optional(),
|
||||
scope: z.object({
|
||||
type: z.enum(['task', 'project', 'note', 'timeline']),
|
||||
id: z.string().optional(),
|
||||
}).optional(),
|
||||
mode: z.enum(['contextual']).optional(),
|
||||
scope: z.unknown().optional(),
|
||||
briefMode: z.boolean().optional(),
|
||||
briefingContext: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
if (input.mode === 'floating' && input.scope) {
|
||||
return await orchestrateFloating({
|
||||
if (input.mode === 'contextual') {
|
||||
return await orchestrateContextual({
|
||||
message: input.message,
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
scope: input.scope,
|
||||
conversationHistory: input.conversationHistory,
|
||||
briefMode: input.briefMode,
|
||||
briefingContext: input.briefingContext,
|
||||
conversationHistory: input.conversationHistory as Array<{ role: string; content: string }> | undefined,
|
||||
sender: ctx.sender,
|
||||
});
|
||||
}
|
||||
@@ -1068,12 +1085,12 @@ const aiRouter = router({
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent router — proxy to backend agent management API
|
||||
// Scout router — proxy to backend scout management API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const agentLocalRouter = router({
|
||||
const scoutLocalRouter = router({
|
||||
list: publicProcedure.query(() => {
|
||||
return getLocalAgents();
|
||||
return getLocalScouts();
|
||||
}),
|
||||
|
||||
create: publicProcedure
|
||||
@@ -1085,7 +1102,7 @@ const agentLocalRouter = router({
|
||||
scheduleCron: z.string(),
|
||||
}))
|
||||
.mutation(({ input }) => {
|
||||
const agent: LocalAgentLocalConfig = {
|
||||
const scout: LocalScoutConfig = {
|
||||
id: crypto.randomUUID(),
|
||||
name: input.name,
|
||||
directory: input.directory,
|
||||
@@ -1095,8 +1112,8 @@ const agentLocalRouter = router({
|
||||
enabled: true,
|
||||
lastRunAt: null,
|
||||
};
|
||||
saveLocalAgent(agent);
|
||||
return { data: agent, error: null };
|
||||
saveLocalScout(scout);
|
||||
return { data: scout, error: null };
|
||||
}),
|
||||
|
||||
update: publicProcedure
|
||||
@@ -1110,11 +1127,11 @@ const agentLocalRouter = router({
|
||||
enabled: z.boolean().optional(),
|
||||
}))
|
||||
.mutation(({ input }) => {
|
||||
const existing = getLocalAgent(input.id);
|
||||
const existing = getLocalScout(input.id);
|
||||
if (!existing) {
|
||||
return { data: null, error: 'Agent not found' };
|
||||
return { data: null, error: 'Scout not found' };
|
||||
}
|
||||
const updated: LocalAgentLocalConfig = {
|
||||
const updated: LocalScoutConfig = {
|
||||
...existing,
|
||||
...(input.name !== undefined && { name: input.name }),
|
||||
...(input.directory !== undefined && { directory: input.directory }),
|
||||
@@ -1123,22 +1140,22 @@ const agentLocalRouter = router({
|
||||
...(input.scheduleCron !== undefined && { scheduleCron: input.scheduleCron }),
|
||||
...(input.enabled !== undefined && { enabled: input.enabled }),
|
||||
};
|
||||
saveLocalAgent(updated);
|
||||
saveLocalScout(updated);
|
||||
return { data: updated, error: null };
|
||||
}),
|
||||
|
||||
delete: publicProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
deleteLocalAgent(input.id);
|
||||
deleteLocalScout(input.id);
|
||||
return { success: true as const, error: null };
|
||||
}),
|
||||
});
|
||||
|
||||
const agentCloudRouter = router({
|
||||
const scoutCloudRouter = router({
|
||||
list: publicProcedure.query(async () => {
|
||||
try {
|
||||
return await getBackendClient().proxyGet<CloudAgentConfig[]>('/api/v1/agents/cloud');
|
||||
return await getBackendClient().proxyGet<CloudScoutConfig[]>('/api/v1/scouts/cloud');
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to list cloud agents';
|
||||
console.error('[Agent] cloud.list error:', msg);
|
||||
@@ -1150,20 +1167,24 @@ const agentCloudRouter = router({
|
||||
.input(z.object({
|
||||
name: z.string(),
|
||||
provider: z.enum(['gmail', 'teams', 'outlook']),
|
||||
dataTypes: z.array(z.string()),
|
||||
promptTemplate: z.string(),
|
||||
scheduleCron: z.string(),
|
||||
filterConfig: z.record(z.string(), z.unknown()).optional(),
|
||||
dataTypes: z.array(z.string()).default([]),
|
||||
promptTemplate: z.string().default(''),
|
||||
scheduleCron: z.string().optional(),
|
||||
filterConfig: z.object({
|
||||
labels: z.array(z.string()).optional(),
|
||||
senders: z.array(z.string()).optional(),
|
||||
}).optional(),
|
||||
autoTrashSpam: z.boolean().optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const result = await getBackendClient().proxyPost<CloudAgentConfig>(
|
||||
'/api/v1/agents/cloud',
|
||||
const result = await getBackendClient().proxyPost<CloudScoutConfig>(
|
||||
'/api/v1/scouts/cloud',
|
||||
input as Record<string, unknown>,
|
||||
);
|
||||
return { data: result, error: null };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to create cloud agent';
|
||||
const msg = err instanceof Error ? err.message : 'Failed to create cloud scout';
|
||||
return { data: null, error: msg };
|
||||
}
|
||||
}),
|
||||
@@ -1175,19 +1196,23 @@ const agentCloudRouter = router({
|
||||
dataTypes: z.array(z.string()).optional(),
|
||||
promptTemplate: z.string().optional(),
|
||||
scheduleCron: z.string().optional(),
|
||||
filterConfig: z.record(z.string(), z.unknown()).optional(),
|
||||
filterConfig: z.object({
|
||||
labels: z.array(z.string()).optional(),
|
||||
senders: z.array(z.string()).optional(),
|
||||
}).optional(),
|
||||
autoTrashSpam: z.boolean().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const { id, ...updates } = input;
|
||||
try {
|
||||
const result = await getBackendClient().proxyPut<CloudAgentConfig>(
|
||||
`/api/v1/agents/cloud/${id}`,
|
||||
const result = await getBackendClient().proxyPut<CloudScoutConfig>(
|
||||
`/api/v1/scouts/cloud/${id}`,
|
||||
updates as Record<string, unknown>,
|
||||
);
|
||||
return { data: result, error: null };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to update cloud agent';
|
||||
const msg = err instanceof Error ? err.message : 'Failed to update cloud scout';
|
||||
return { data: null, error: msg };
|
||||
}
|
||||
}),
|
||||
@@ -1196,16 +1221,117 @@ const agentCloudRouter = router({
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
await getBackendClient().proxyDelete<{ ok: boolean }>(`/api/v1/agents/cloud/${input.id}`);
|
||||
await getBackendClient().proxyDelete<{ ok: boolean }>(`/api/v1/scouts/cloud/${input.id}`);
|
||||
return { success: true as const, error: null };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to delete cloud agent';
|
||||
return { success: false as const, error: msg };
|
||||
}
|
||||
}),
|
||||
|
||||
startGmailOAuth: publicProcedure
|
||||
.input(z.object({ scoutId: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const data = await getBackendClient().proxyGet<{ authorizeUrl: string }>(
|
||||
`/api/v1/scouts/oauth/gmail/authorize?scout_id=${encodeURIComponent(input.scoutId)}`,
|
||||
);
|
||||
await shell.openExternal(data.authorizeUrl);
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
// Creation-mode OAuth: no scout exists yet. The draft is held server-side in
|
||||
// the pending OAuth session; the scout is created at finalizeCloudScout.
|
||||
startGmailOAuthDraft: publicProcedure
|
||||
.input(z.object({
|
||||
name: z.string(),
|
||||
promptTemplate: z.string(),
|
||||
autoTrashSpam: z.boolean(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
// proxyPost snake_cases the body and camelCases the response.
|
||||
const data = await getBackendClient().proxyPost<{ authorizeUrl: string }>(
|
||||
'/api/v1/scouts/oauth/gmail/authorize-draft',
|
||||
input as Record<string, unknown>,
|
||||
);
|
||||
await shell.openExternal(data.authorizeUrl);
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
completeGmailOAuth: publicProcedure
|
||||
.input(z.object({ code: z.string(), state: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
return await getBackendClient().proxyPost<{ ok: boolean; sessionId: string | null; gmailAddress: string | null }>(
|
||||
'/api/v1/scouts/oauth/gmail/callback',
|
||||
input as Record<string, unknown>,
|
||||
);
|
||||
}),
|
||||
|
||||
gmailLabels: publicProcedure
|
||||
.input(z.object({ scoutId: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
return await getBackendClient().proxyGet<{ id: string; name: string }[]>(
|
||||
`/api/v1/scouts/cloud/${input.scoutId}/gmail-labels`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[Scout] gmailLabels error:', err instanceof Error ? err.message : err);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
|
||||
// Labels for a pending create-mode session (no scout row yet).
|
||||
gmailSessionLabels: publicProcedure
|
||||
.input(z.object({ session: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
return await getBackendClient().proxyGet<{ id: string; name: string }[]>(
|
||||
`/api/v1/scouts/oauth/gmail/session-labels?session=${encodeURIComponent(input.session)}`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[Scout] gmailSessionLabels error:', err instanceof Error ? err.message : err);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
|
||||
// Create the cloud scout at the end of the create-mode flow.
|
||||
finalizeCloudScout: publicProcedure
|
||||
.input(z.object({
|
||||
session: z.string(),
|
||||
filterConfig: z.object({
|
||||
labels: z.array(z.string()).optional(),
|
||||
senders: z.array(z.string()).optional(),
|
||||
}).optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const result = await getBackendClient().proxyPost<CloudScoutConfig>(
|
||||
'/api/v1/scouts/cloud/finalize',
|
||||
input as Record<string, unknown>,
|
||||
);
|
||||
return { data: result, error: null };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to finalize cloud scout';
|
||||
return { data: null, error: msg };
|
||||
}
|
||||
}),
|
||||
|
||||
disconnectGmail: publicProcedure
|
||||
.input(z.object({ scoutId: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const result = await getBackendClient().proxyPost<CloudScoutConfig>(
|
||||
`/api/v1/scouts/cloud/${input.scoutId}/gmail-disconnect`,
|
||||
{},
|
||||
);
|
||||
return { data: result, error: null };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to disconnect Gmail';
|
||||
return { data: null, error: msg };
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
const agentJourneyRouter = router({
|
||||
const scoutJourneyRouter = router({
|
||||
start: publicProcedure
|
||||
.input(z.object({
|
||||
agentType: z.enum(['local_directory', 'gmail', 'teams', 'outlook']),
|
||||
@@ -1243,20 +1369,20 @@ const agentJourneyRouter = router({
|
||||
}),
|
||||
});
|
||||
|
||||
const agentRouter = router({
|
||||
/** Agent catalog — available agent types from the backend. */
|
||||
const scoutRouter = router({
|
||||
/** Scout catalog — available scout types from the backend. */
|
||||
catalog: publicProcedure.query(async () => {
|
||||
try {
|
||||
return await getBackendClient().proxyGet<AgentCatalogItem[]>('/api/v1/agents/catalog');
|
||||
return await getBackendClient().proxyGet<AgentCatalogItem[]>('/api/v1/scouts/catalog');
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to load catalog';
|
||||
console.error('[Agent] catalog error:', msg);
|
||||
console.error('[Scout] catalog error:', msg);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
|
||||
local: agentLocalRouter,
|
||||
cloud: agentCloudRouter,
|
||||
local: scoutLocalRouter,
|
||||
cloud: scoutCloudRouter,
|
||||
|
||||
/** Run history — queries local SQLite (data written by backend-client on tool_call/run_complete). */
|
||||
runs: publicProcedure
|
||||
@@ -1272,18 +1398,18 @@ const agentRouter = router({
|
||||
const offset = input.offset ?? 0;
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(agentRuns)
|
||||
.where(eq(agentRuns.agentId, input.agentId))
|
||||
.orderBy(desc(agentRuns.startedAt))
|
||||
.from(scoutRuns)
|
||||
.where(eq(scoutRuns.scoutId, input.agentId))
|
||||
.orderBy(desc(scoutRuns.startedAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
// Compute per-run action counts in one query
|
||||
const runIds = rows.map(r => r.id);
|
||||
const actionRows = runIds.length > 0
|
||||
? await db.select({ runId: agentRunActions.runId, verb: agentRunActions.verb, entityType: agentRunActions.entityType })
|
||||
.from(agentRunActions)
|
||||
.where(inArray(agentRunActions.runId, runIds))
|
||||
? await db.select({ runId: scoutRunActions.runId, verb: scoutRunActions.verb, entityType: scoutRunActions.entityType })
|
||||
.from(scoutRunActions)
|
||||
.where(inArray(scoutRunActions.runId, runIds))
|
||||
: [];
|
||||
|
||||
type ActionCounts = { created: number; updated: number; deleted: number };
|
||||
@@ -1302,7 +1428,7 @@ const agentRouter = router({
|
||||
}));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to load run history';
|
||||
console.error('[Agent] runs error:', msg);
|
||||
console.error('[Scout] runs error:', msg);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
@@ -1315,60 +1441,60 @@ const agentRouter = router({
|
||||
const db = getDb();
|
||||
return await db
|
||||
.select()
|
||||
.from(agentRunActions)
|
||||
.where(eq(agentRunActions.runId, input.runId))
|
||||
.orderBy(asc(agentRunActions.createdAt));
|
||||
.from(scoutRunActions)
|
||||
.where(eq(scoutRunActions.runId, input.runId))
|
||||
.orderBy(asc(scoutRunActions.createdAt));
|
||||
} catch (err) {
|
||||
console.error('[Agent] runActions error:', err);
|
||||
console.error('[Scout] runActions error:', err);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
|
||||
/** Check whether the user's plan allows creating a new agent. */
|
||||
/** Check whether the user's plan allows creating a new scout. */
|
||||
canCreate: publicProcedure.mutation(async () => {
|
||||
try {
|
||||
const activeAgents = getLocalAgents().length;
|
||||
const activeScouts = getLocalScouts().length;
|
||||
const result = await getBackendClient().proxyPost<{ allowed: boolean; tier: string; activeAgents: number; limit: number }>(
|
||||
'/api/v1/agents/can-create',
|
||||
{ activeAgents },
|
||||
'/api/v1/scouts/can-create',
|
||||
{ activeAgents: activeScouts },
|
||||
);
|
||||
return { data: result, error: null };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to check agent quota';
|
||||
const msg = err instanceof Error ? err.message : 'Failed to check scout quota';
|
||||
return { data: null, error: msg };
|
||||
}
|
||||
}),
|
||||
|
||||
/** Manually trigger a local agent run via the BE two-phase runner. */
|
||||
/** Manually trigger a local scout run via the BE two-phase runner. */
|
||||
runNow: publicProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const agent = getLocalAgent(input.id);
|
||||
if (!agent) return { data: null, error: 'Agent not found' };
|
||||
const activeAgents = getLocalAgents().length;
|
||||
const scout = getLocalScout(input.id);
|
||||
if (!scout) return { data: null, error: 'Scout not found' };
|
||||
const activeScouts = getLocalScouts().length;
|
||||
console.log(
|
||||
`[agents.runNow] Triggering agent "${agent.name}" (id=${agent.id}) with lastRunAt=${agent.lastRunAt} (${agent.lastRunAt ? new Date(agent.lastRunAt).toISOString() : 'null'})`,
|
||||
`[scout.runNow] Triggering scout "${scout.name}" (id=${scout.id}) with lastRunAt=${scout.lastRunAt} (${scout.lastRunAt ? new Date(scout.lastRunAt).toISOString() : 'null'})`,
|
||||
);
|
||||
const result = await getBackendClient().proxyPost<{ id: string }>(
|
||||
'/api/v1/agents/trigger',
|
||||
'/api/v1/scouts/trigger',
|
||||
{
|
||||
directory: agent.directory,
|
||||
directory: scout.directory,
|
||||
deviceId: getDeviceId(),
|
||||
agentId: agent.id,
|
||||
whatToExtract: agent.dataTypes,
|
||||
batchInterval: agent.scheduleCron,
|
||||
agentConfig: agent.agentConfig ?? undefined,
|
||||
activeAgents,
|
||||
lastRunAt: agent.lastRunAt ?? undefined,
|
||||
agentId: scout.id,
|
||||
whatToExtract: scout.dataTypes,
|
||||
batchInterval: scout.scheduleCron,
|
||||
agentConfig: scout.agentConfig ?? undefined,
|
||||
activeAgents: activeScouts,
|
||||
lastRunAt: scout.lastRunAt ?? undefined,
|
||||
},
|
||||
);
|
||||
// Create the run row so it appears in history even with zero mutations
|
||||
if (result?.id) {
|
||||
try {
|
||||
await getDb().insert(agentRuns).values({
|
||||
await getDb().insert(scoutRuns).values({
|
||||
id: result.id,
|
||||
agentId: agent.id,
|
||||
scoutId: scout.id,
|
||||
status: 'running',
|
||||
startedAt: Date.now(),
|
||||
}).onConflictDoNothing();
|
||||
@@ -1376,12 +1502,12 @@ const agentRouter = router({
|
||||
}
|
||||
return { data: result, error: null };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to trigger agent run';
|
||||
const msg = err instanceof Error ? err.message : 'Failed to trigger scout run';
|
||||
return { data: null, error: msg };
|
||||
}
|
||||
}),
|
||||
|
||||
journey: agentJourneyRouter,
|
||||
journey: scoutJourneyRouter,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1739,6 +1865,85 @@ const noteEditsRouter = router({
|
||||
}),
|
||||
});
|
||||
|
||||
const taskAttachmentsRouter = router({
|
||||
list: publicProcedure
|
||||
.input(z.object({ taskId: z.string() }))
|
||||
.query(({ input }) => {
|
||||
return getDb()
|
||||
.select()
|
||||
.from(taskAttachments)
|
||||
.where(eq(taskAttachments.taskId, input.taskId))
|
||||
.orderBy(asc(taskAttachments.createdAt))
|
||||
.all();
|
||||
}),
|
||||
|
||||
pick: publicProcedure.mutation(async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
});
|
||||
if (result.canceled) return [];
|
||||
const out: Array<{ path: string; name: string; size: number }> = [];
|
||||
for (const p of result.filePaths) {
|
||||
const s = await stat(p);
|
||||
out.push({ path: p, name: p.split(/[\\/]/).pop() ?? p, size: s.size });
|
||||
}
|
||||
return out;
|
||||
}),
|
||||
|
||||
create: publicProcedure
|
||||
.input(z.object({
|
||||
taskId: z.string(),
|
||||
sourcePath: z.string(),
|
||||
filename: z.string(),
|
||||
sizeBytes: z.number().int().nonnegative(),
|
||||
mimeType: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const db = getDb();
|
||||
const { storedPath } = await copyIntoTask(input.taskId, input.sourcePath, input.filename);
|
||||
const row = {
|
||||
id: randomUUID(),
|
||||
taskId: input.taskId,
|
||||
filename: input.filename,
|
||||
mimeType: input.mimeType ?? null,
|
||||
sizeBytes: input.sizeBytes,
|
||||
storedPath,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
db.insert(taskAttachments).values(row).run();
|
||||
return row;
|
||||
}),
|
||||
|
||||
delete: publicProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.select()
|
||||
.from(taskAttachments)
|
||||
.where(eq(taskAttachments.id, input.id))
|
||||
.get();
|
||||
if (!row) return { ok: false };
|
||||
await deleteStored(row.storedPath);
|
||||
db.delete(taskAttachments).where(eq(taskAttachments.id, input.id)).run();
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
open: publicProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.select()
|
||||
.from(taskAttachments)
|
||||
.where(eq(taskAttachments.id, input.id))
|
||||
.get();
|
||||
if (!row) return { ok: false };
|
||||
const err = await shell.openPath(absolutePath(row.storedPath));
|
||||
return { ok: err === '' };
|
||||
}),
|
||||
});
|
||||
|
||||
export const appRouter = router({
|
||||
health: healthRouter,
|
||||
settings: settingsRouter,
|
||||
@@ -1750,10 +1955,13 @@ export const appRouter = router({
|
||||
notes: notesRouter,
|
||||
noteEdits: noteEditsRouter,
|
||||
taskComments: taskCommentsRouter,
|
||||
taskAttachments: taskAttachmentsRouter,
|
||||
ai: aiRouter,
|
||||
auth: authRouter,
|
||||
agent: agentRouter,
|
||||
scout: scoutRouter,
|
||||
memory: memoryRouter,
|
||||
projectFolders: projectFoldersRouter,
|
||||
aiChat: aiChatRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
128
src/main/router/projectFolders.ts
Normal file
128
src/main/router/projectFolders.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
// adiuvAI/src/main/router/projectFolders.ts
|
||||
import { TRPCError, initTRPC } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { dialog } from 'electron';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDb } from '../db';
|
||||
import { projects, projectFolderFiles } from '../db/schema';
|
||||
import { startIndexSession, type IndexProgress } from '../files/indexer';
|
||||
import { scanFolder } from '../files/scanner';
|
||||
import { getBackendClient, QuotaError } from '../api/backend-client';
|
||||
import type { TRPCContext } from '../ipc';
|
||||
|
||||
const t = initTRPC.context<TRPCContext>().create();
|
||||
const router = t.router;
|
||||
const publicProcedure = t.procedure;
|
||||
|
||||
// In-memory map of active sessions per projectId so we can cancel
|
||||
const _active = new Map<string, { cancel: () => void; lastProgress: IndexProgress }>();
|
||||
|
||||
export const projectFoldersRouter = router({
|
||||
chooseFolder: publicProcedure.mutation(async () => {
|
||||
const result = await dialog.showOpenDialog({ properties: ['openDirectory'] });
|
||||
if (result.canceled || result.filePaths.length === 0) return null;
|
||||
return result.filePaths[0];
|
||||
}),
|
||||
|
||||
link: publicProcedure
|
||||
.input(z.object({ projectId: z.string(), folderPath: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const db = getDb();
|
||||
db.update(projects)
|
||||
.set({ folderPath: input.folderPath, folderLastScanStatus: 'idle', folderTotalFiles: 0 })
|
||||
.where(eq(projects.id, input.projectId))
|
||||
.run();
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
unlink: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const db = getDb();
|
||||
db.delete(projectFolderFiles).where(eq(projectFolderFiles.projectId, input.projectId)).run();
|
||||
db.update(projects)
|
||||
.set({
|
||||
folderPath: null,
|
||||
folderLastScannedAt: null,
|
||||
folderLastScanStatus: 'idle',
|
||||
folderTotalFiles: 0,
|
||||
})
|
||||
.where(eq(projects.id, input.projectId))
|
||||
.run();
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
startScan: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const db = getDb();
|
||||
const proj = db.select().from(projects).where(eq(projects.id, input.projectId)).get();
|
||||
if (!proj?.folderPath) throw new Error('No folder linked');
|
||||
if (proj.folderLastScanStatus === 'scanning') throw new Error('Scan already in progress');
|
||||
|
||||
// Pre-flight: walk folder to estimate indexable file count, then ask the
|
||||
// backend whether the user's tier allows proceeding.
|
||||
const delta = await scanFolder(input.projectId, proj.folderPath);
|
||||
const estimated = delta.newFiles.length + delta.changedFiles.length + delta.unchangedCount;
|
||||
|
||||
try {
|
||||
await getBackendClient().checkFolderQuota(estimated);
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaError) {
|
||||
// Encode reason + backend message so the renderer can produce a
|
||||
// localised toast without an extra RPC call.
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: `QUOTA:${err.reason}:${err.message}`,
|
||||
});
|
||||
}
|
||||
// Network / auth errors: propagate as-is so the renderer shows a
|
||||
// generic error toast rather than silently swallowing the problem.
|
||||
throw err;
|
||||
}
|
||||
|
||||
const session = await startIndexSession(input.projectId, (p) => {
|
||||
const entry = _active.get(input.projectId);
|
||||
if (entry) entry.lastProgress = p;
|
||||
if (
|
||||
p.status === 'completed' ||
|
||||
p.status === 'cancelled' ||
|
||||
p.status === 'quota_exceeded' ||
|
||||
p.status === 'error'
|
||||
) {
|
||||
_active.delete(input.projectId);
|
||||
}
|
||||
});
|
||||
_active.set(input.projectId, {
|
||||
cancel: session.cancel,
|
||||
lastProgress: { sessionId: session.sessionId, processed: 0, total: 0, status: 'starting' },
|
||||
});
|
||||
return { sessionId: session.sessionId };
|
||||
}),
|
||||
|
||||
cancelScan: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const entry = _active.get(input.projectId);
|
||||
if (entry) entry.cancel();
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
getStatus: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(({ input }) => {
|
||||
const entry = _active.get(input.projectId);
|
||||
return entry?.lastProgress ?? null;
|
||||
}),
|
||||
|
||||
listFiles: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(({ input }) => {
|
||||
return getDb()
|
||||
.select()
|
||||
.from(projectFolderFiles)
|
||||
.where(eq(projectFolderFiles.projectId, input.projectId))
|
||||
.orderBy(projectFolderFiles.relativePath)
|
||||
.all();
|
||||
}),
|
||||
});
|
||||
@@ -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';
|
||||
type ChatInputBoxVariant = 'panel' | 'comment';
|
||||
|
||||
interface ChatInputBoxProps {
|
||||
cacheKey: string;
|
||||
@@ -27,10 +27,10 @@ const VARIANT_STYLES = {
|
||||
button: 'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100',
|
||||
iconSize: 16,
|
||||
},
|
||||
floating: {
|
||||
container: 'flex items-center gap-2 px-3 py-2.5',
|
||||
textarea: 'flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground/60 outline-none max-h-20 overflow-y-auto',
|
||||
button: 'flex h-7 w-7 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed',
|
||||
comment: {
|
||||
container: 'flex items-center gap-2 px-3 py-2',
|
||||
textarea: 'flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground outline-none max-h-32 overflow-y-auto',
|
||||
button: 'flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed',
|
||||
iconSize: 14,
|
||||
},
|
||||
} as const;
|
||||
@@ -43,7 +43,7 @@ export const ChatInputBox = forwardRef<ChatInputBoxHandle, ChatInputBoxProps>(
|
||||
const valueRef = useRef(value);
|
||||
valueRef.current = value;
|
||||
|
||||
// Re-init when the cache key changes (context switches in FloatingChat).
|
||||
// Re-init when the cache key changes (context switches).
|
||||
const prevKeyRef = useRef(cacheKey);
|
||||
useEffect(() => {
|
||||
if (prevKeyRef.current !== cacheKey) {
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -3,8 +3,9 @@ import { useNavigate } from '@tanstack/react-router';
|
||||
import { FileText, FolderOpen, Sparkles } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
|
||||
import { TaskDetailDialog } from '@/components/tasks/TaskDetailDialog';
|
||||
import { TaskRow } from '@/components/tasks/TaskRow';
|
||||
import type { TaskItem } from '@/components/tasks/task-types';
|
||||
import { TaskDetailSheet } from '@/components/tasks/TaskDetailSheet';
|
||||
import { EditTaskDialog } from '@/components/tasks/EditTaskDialog';
|
||||
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
|
||||
import { ChatTimelineBlock } from './ChatTimelineBlock';
|
||||
@@ -84,7 +85,7 @@ function TaskEntityBlock({ ids }: { ids: string[] }) {
|
||||
))}
|
||||
</EntityWrapper>
|
||||
|
||||
<TaskDetailDialog
|
||||
<TaskDetailSheet
|
||||
task={viewTask}
|
||||
open={!!viewTask}
|
||||
onOpenChange={(open) => { if (!open) setViewTask(null); }}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import { memo, useState, useMemo, useCallback } from 'react';
|
||||
import { DragDropContext, Droppable, Draggable, type DropResult } from '@hello-pangea/dnd';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
|
||||
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
|
||||
import { EditTaskDialog } from '@/components/tasks/EditTaskDialog';
|
||||
import { TaskDetailDialog } from '@/components/tasks/TaskDetailDialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const COLUMNS = [
|
||||
{ id: 'todo', labelKey: 'tasks.toDo' },
|
||||
{ id: 'in_progress', labelKey: 'tasks.inProgress' },
|
||||
{ id: 'done', labelKey: 'tasks.completed' },
|
||||
] as const;
|
||||
|
||||
type ColumnId = (typeof COLUMNS)[number]['id'];
|
||||
|
||||
type KanbanBoardProps = {
|
||||
projectId: string;
|
||||
newTaskOpen: boolean;
|
||||
onNewTaskOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
function KanbanBoardInner({ projectId, newTaskOpen, onNewTaskOpenChange }: KanbanBoardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { state: floatingState } = useFloatingChat();
|
||||
const { data: tasksList } = trpc.tasks.list.useQuery({ projectId });
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { notify, notifyError } = useNotify();
|
||||
|
||||
const updateTask = trpc.tasks.update.useMutation({
|
||||
onSuccess: () => void utils.tasks.list.invalidate(),
|
||||
onError: (err) => notifyError('toast.task.updateError', err),
|
||||
});
|
||||
|
||||
const deleteTask = trpc.tasks.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
notify('warning', 'toast.task.deleted');
|
||||
void utils.tasks.list.invalidate();
|
||||
},
|
||||
onError: (err) => notifyError('toast.task.deleteError', err),
|
||||
});
|
||||
|
||||
// Edit / view task dialog state
|
||||
const [editTask, setEditTask] = useState<TaskItem | null>(null);
|
||||
const [viewTask, setViewTask] = useState<TaskItem | null>(null);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const tasks = tasksList ?? [];
|
||||
const grouped: Record<ColumnId, TaskItem[]> = {
|
||||
todo: [],
|
||||
in_progress: [],
|
||||
done: [],
|
||||
};
|
||||
for (const task of tasks) {
|
||||
const status = (task.status ?? 'todo') as ColumnId;
|
||||
if (status in grouped) {
|
||||
grouped[status].push(task);
|
||||
} else {
|
||||
grouped.todo.push(task);
|
||||
}
|
||||
}
|
||||
return grouped;
|
||||
}, [tasksList]);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(result: DropResult) => {
|
||||
const { destination, source, draggableId } = result;
|
||||
if (!destination) return;
|
||||
if (destination.droppableId === source.droppableId) return;
|
||||
|
||||
updateTask.mutate({
|
||||
id: draggableId,
|
||||
status: destination.droppableId,
|
||||
});
|
||||
},
|
||||
[updateTask],
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(taskId: string, currentStatus: string | null) => {
|
||||
const nextStatus =
|
||||
currentStatus === 'todo' ? 'in_progress' :
|
||||
currentStatus === 'in_progress' ? 'done' : 'todo';
|
||||
updateTask.mutate({ id: taskId, status: nextStatus });
|
||||
},
|
||||
[updateTask],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{COLUMNS.map((col) => (
|
||||
<div key={col.id} className="flex flex-col gap-3">
|
||||
{/* Column header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{t(col.labelKey)}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{columns[col.id].length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Droppable column */}
|
||||
<Droppable droppableId={col.id}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
className={`flex flex-col gap-2 min-h-[120px] rounded-md transition-colors ${
|
||||
snapshot.isDraggingOver ? 'bg-muted/50' : 'bg-muted/20'
|
||||
}`}
|
||||
>
|
||||
{columns[col.id].map((task, index) => (
|
||||
<Draggable
|
||||
key={task.id}
|
||||
draggableId={task.id}
|
||||
index={index}
|
||||
>
|
||||
{(dragProvided) => (
|
||||
<div
|
||||
ref={dragProvided.innerRef}
|
||||
{...dragProvided.draggableProps}
|
||||
{...dragProvided.dragHandleProps}
|
||||
>
|
||||
<TaskRow
|
||||
task={task}
|
||||
onToggle={handleToggle}
|
||||
onEdit={setEditTask}
|
||||
onDelete={(id) => deleteTask.mutate({ id })}
|
||||
onClick={setViewTask}
|
||||
hideBreadcrumb
|
||||
layoutId={
|
||||
floatingState.morphTargetId === `task-morph-${task.id}`
|
||||
? floatingState.morphTargetId
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DragDropContext>
|
||||
|
||||
<NewTaskDialog
|
||||
open={newTaskOpen}
|
||||
onOpenChange={onNewTaskOpenChange}
|
||||
defaultProjectId={projectId}
|
||||
/>
|
||||
<EditTaskDialog
|
||||
task={editTask}
|
||||
open={!!editTask}
|
||||
onOpenChange={(open) => { if (!open) setEditTask(null); }}
|
||||
/>
|
||||
<TaskDetailDialog
|
||||
task={viewTask}
|
||||
open={!!viewTask}
|
||||
onOpenChange={(open) => { if (!open) setViewTask(null); }}
|
||||
onEdit={(task) => { setViewTask(null); setEditTask(task); }}
|
||||
onDelete={(id) => { deleteTask.mutate({ id }); setViewTask(null); }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const KanbanBoard = memo(KanbanBoardInner);
|
||||
@@ -14,18 +14,20 @@ import {
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { KanbanBoard } from './KanbanBoard';
|
||||
import { TaskListView } from '@/components/tasks/TaskListView';
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
|
||||
import { type TimelineEvent } from '@/components/timeline/ProjectTimeline';
|
||||
import { type ProjectGroup } from '@/components/timeline/ProjectTimelineBox';
|
||||
import { TimelineGanttView } from '@/components/timeline/TimelineGanttView';
|
||||
import { AddEventDialog } from '@/components/timeline/AddEventDialog';
|
||||
import { EditEventDialog } from '@/components/timeline/EditEventDialog';
|
||||
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
import { useTimelineHistory } from '@/hooks/useTimelineHistory';
|
||||
import type { EventSnapshot } from '@/components/timeline/history-types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ProjectTabBar, SECTIONS, type SectionId } from './ProjectTabBar';
|
||||
import { FolderChip } from './folder/FolderChip';
|
||||
import { FilesSection } from './folder/FilesSection';
|
||||
import { useContextualScope } from '@/hooks/useContextualScope';
|
||||
|
||||
type ProjectDetailProps = {
|
||||
projectId: string;
|
||||
@@ -36,7 +38,6 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
const { t } = useTranslation();
|
||||
const prefs = useFormatPrefs();
|
||||
const navigate = useNavigate();
|
||||
const [newTaskOpen, setNewTaskOpen] = useState(false);
|
||||
const [addEventOpen, setAddEventOpen] = useState(false);
|
||||
const [editingEvent, setEditingEvent] = useState<TimelineEvent | null>(null);
|
||||
const [compact, setCompact] = useState(false);
|
||||
@@ -47,18 +48,24 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const tasksRef = useRef<HTMLDivElement>(null);
|
||||
const notesRef = useRef<HTMLDivElement>(null);
|
||||
const filesRef = useRef<HTMLDivElement>(null);
|
||||
const sectionRefs: Record<SectionId, React.RefObject<HTMLDivElement | null>> = useMemo(() => ({
|
||||
overview: summaryRef,
|
||||
timeline: timelineRef,
|
||||
tasks: tasksRef,
|
||||
notes: notesRef,
|
||||
files: filesRef,
|
||||
}), []);
|
||||
|
||||
const didInitialScroll = useRef(false);
|
||||
|
||||
const { registerSection, unregisterSection } = useFloatingChat();
|
||||
const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
|
||||
|
||||
const { data: scanStatus } = trpc.projectFolders.getStatus.useQuery(
|
||||
{ projectId },
|
||||
{ refetchInterval: (query) => query.state.data?.status === 'scanning' ? 1000 : false },
|
||||
);
|
||||
|
||||
const {
|
||||
historyOpRef,
|
||||
pendingCreatePayloadRef,
|
||||
@@ -79,18 +86,6 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
const clearHistoryRef = useRef(clearHistory);
|
||||
clearHistoryRef.current = clearHistory;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !project) return;
|
||||
registerSection({ id: 'project-summary', label: 'Project Summary', ref: summaryRef, projectId });
|
||||
registerSection({ id: 'project-tasks', label: 'Tasks', ref: tasksRef, projectId });
|
||||
registerSection({ id: 'project-notes', label: 'Notes', ref: notesRef, projectId });
|
||||
return () => {
|
||||
unregisterSection('project-summary');
|
||||
unregisterSection('project-tasks');
|
||||
unregisterSection('project-notes');
|
||||
};
|
||||
}, [projectId, isLoading, project, registerSection, unregisterSection]);
|
||||
|
||||
// Compact hero on scroll. scrollRef is the definitive scroll container
|
||||
// (flex-1 min-h-0 overflow-y-auto inside a flex-col parent with min-h-0
|
||||
// at every ancestor, so h-full never resolves to content-height).
|
||||
@@ -150,6 +145,20 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
const { data: tasksList } = trpc.tasks.list.useQuery({ projectId });
|
||||
const { data: eventsList } = trpc.timelineEvents.list.useQuery({ projectId });
|
||||
|
||||
useContextualScope({
|
||||
page: 'project',
|
||||
entityType: project ? 'project' : null,
|
||||
entityId: project?.id,
|
||||
entityName: project?.name,
|
||||
counts: project
|
||||
? {
|
||||
tasks: tasksList?.length ?? 0,
|
||||
notes: notesList?.length ?? 0,
|
||||
milestones: (eventsList ?? []).filter((e) => e.type === 'milestone').length,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const breadcrumbPath = useMemo(() => {
|
||||
if (!project?.clientId || !clientsList) return [];
|
||||
const clientMap = new Map(clientsList.map((c) => [c.id, c]));
|
||||
@@ -442,22 +451,58 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
)}
|
||||
<h1
|
||||
className={cn(
|
||||
'font-semibold tracking-tight transition-[font-size,line-height] duration-200 ease-out',
|
||||
compact ? 'text-base leading-tight' : 'text-3xl leading-tight',
|
||||
)}
|
||||
>
|
||||
{projectName}
|
||||
{projectName && subtitle && (
|
||||
compact
|
||||
? <span className="text-muted-foreground/60 font-normal"> · </span>
|
||||
: <br />
|
||||
)}
|
||||
<span className={cn(compact ? 'text-muted-foreground/60 font-normal' : 'text-muted-foreground/50')}>
|
||||
{subtitle}
|
||||
</span>
|
||||
</h1>
|
||||
<div className="flex items-start gap-4">
|
||||
<h1
|
||||
className={cn(
|
||||
'flex-1 min-w-0 font-semibold tracking-tight transition-[font-size,line-height] duration-200 ease-out',
|
||||
compact ? 'text-base leading-tight' : 'text-3xl leading-tight',
|
||||
)}
|
||||
>
|
||||
{projectName}
|
||||
{projectName && subtitle && (
|
||||
compact
|
||||
? <span className="text-muted-foreground/60 font-normal"> · </span>
|
||||
: <br />
|
||||
)}
|
||||
<span className={cn(compact ? 'text-muted-foreground/60 font-normal' : 'text-muted-foreground/50')}>
|
||||
{subtitle}
|
||||
</span>
|
||||
</h1>
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden transition-all duration-200 ease-out',
|
||||
compact ? 'max-w-0 max-h-0 opacity-0' : 'max-w-xs max-h-8 opacity-100',
|
||||
)}
|
||||
>
|
||||
<FolderChip
|
||||
projectId={project.id}
|
||||
folderPath={project.folderPath ?? null}
|
||||
totalFiles={project.folderTotalFiles ?? 0}
|
||||
lastScannedAt={project.folderLastScannedAt ?? null}
|
||||
scanStatus={(project.folderLastScanStatus ?? 'idle') as 'idle' | 'scanning' | 'error'}
|
||||
scanProgress={scanStatus && scanStatus.status === 'scanning'
|
||||
? { processed: scanStatus.processed, total: scanStatus.total }
|
||||
: null}
|
||||
onClick={() => {
|
||||
const el = scrollRef.current;
|
||||
const ref = filesRef.current;
|
||||
if (el && ref) {
|
||||
const heroH = heroRef.current?.getBoundingClientRect().height ?? 88;
|
||||
const sectionTop = ref.getBoundingClientRect().top;
|
||||
const containerTop = el.getBoundingClientRect().top;
|
||||
const top = el.scrollTop + sectionTop - containerTop - heroH - 41;
|
||||
el.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
|
||||
}
|
||||
void navigate({
|
||||
search: (prev: Record<string, string | undefined>) => ({ ...prev, tab: 'files' }),
|
||||
replace: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -476,7 +521,6 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
<section
|
||||
ref={summaryRef}
|
||||
data-section="overview"
|
||||
data-ai-section="project-summary"
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('projects.overview')}</h1>
|
||||
@@ -536,8 +580,6 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
onEdit={handleEditEvent}
|
||||
onDuplicate={handleDuplicate}
|
||||
onMove={handleMoveEvent}
|
||||
sectionId="project-timeline"
|
||||
sectionLabel="Project Timeline"
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
@@ -558,28 +600,16 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
<section
|
||||
ref={tasksRef}
|
||||
data-section="tasks"
|
||||
data-ai-section="project-tasks"
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('projects.tasks')}</h1>
|
||||
<Button size="sm" onClick={() => setNewTaskOpen(true)}>
|
||||
<Plus data-icon="inline-start" />
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
</div>
|
||||
<KanbanBoard
|
||||
projectId={projectId}
|
||||
newTaskOpen={newTaskOpen}
|
||||
onNewTaskOpenChange={setNewTaskOpen}
|
||||
/>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('projects.tasks')}</h1>
|
||||
<TaskListView projectId={projectId} hideProjectColumn />
|
||||
</section>
|
||||
|
||||
{/* Notes section */}
|
||||
<section
|
||||
ref={notesRef}
|
||||
data-section="notes"
|
||||
data-ai-section="project-notes"
|
||||
className="flex flex-col gap-4 pb-16"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -624,6 +654,22 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Files section */}
|
||||
<section
|
||||
ref={filesRef}
|
||||
data-section="files"
|
||||
className="flex flex-col gap-4 pb-16"
|
||||
>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('projects.folder.title')}</h1>
|
||||
<FilesSection
|
||||
projectId={project.id}
|
||||
folderPath={project.folderPath ?? null}
|
||||
totalFiles={project.folderTotalFiles ?? 0}
|
||||
lastScannedAt={project.folderLastScannedAt ?? null}
|
||||
scanStatus={(project.folderLastScanStatus ?? 'idle') as 'idle' | 'scanning' | 'error'}
|
||||
/>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,13 +3,13 @@ import { useNavigate } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const SECTIONS = ['overview', 'timeline', 'tasks', 'notes'] as const;
|
||||
export const SECTIONS = ['overview', 'timeline', 'tasks', 'notes', 'files'] as const;
|
||||
export type SectionId = typeof SECTIONS[number];
|
||||
|
||||
interface ProjectTabBarProps {
|
||||
sectionRefs: Record<SectionId, RefObject<HTMLDivElement | null>>;
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
heroRef: RefObject<HTMLDivElement | null>;
|
||||
heroRef?: RefObject<HTMLDivElement | null>;
|
||||
initialTab?: string;
|
||||
}
|
||||
|
||||
@@ -20,10 +20,23 @@ export function ProjectTabBar({ sectionRefs, scrollRef, heroRef, initialTab }: P
|
||||
(SECTIONS.includes(initialTab as SectionId) ? initialTab : 'overview') as SectionId,
|
||||
);
|
||||
|
||||
// Live hero height — kept in state so both the sticky top offset and the
|
||||
// IntersectionObserver rootMargin update automatically when the hero resizes
|
||||
// (compact ↔ expanded transition) or when it first appears after data loads.
|
||||
const [heroH, setHeroH] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = heroRef?.current;
|
||||
if (!el) return;
|
||||
const measure = () => setHeroH(el.getBoundingClientRect().height);
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [heroRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = scrollRef.current;
|
||||
if (!root) return;
|
||||
const heroH = heroRef.current?.getBoundingClientRect().height ?? 88;
|
||||
const tabBarH = 41;
|
||||
const visible = new Map<SectionId, IntersectionObserverEntry>();
|
||||
const observer = new IntersectionObserver(
|
||||
@@ -56,7 +69,7 @@ export function ProjectTabBar({ sectionRefs, scrollRef, heroRef, initialTab }: P
|
||||
if (ref.current) observer.observe(ref.current);
|
||||
}
|
||||
return () => observer.disconnect();
|
||||
}, [sectionRefs, scrollRef, heroRef]);
|
||||
}, [sectionRefs, scrollRef, heroH]);
|
||||
|
||||
const scrollToSection = useCallback((id: SectionId) => {
|
||||
const el = scrollRef.current;
|
||||
@@ -66,29 +79,31 @@ export function ProjectTabBar({ sectionRefs, scrollRef, heroRef, initialTab }: P
|
||||
} else {
|
||||
const ref = sectionRefs[id];
|
||||
if (!ref?.current) return;
|
||||
const heroH = heroRef.current?.getBoundingClientRect().height ?? 88;
|
||||
const currentHeroH = heroRef?.current?.getBoundingClientRect().height ?? heroH;
|
||||
const sectionTop = ref.current.getBoundingClientRect().top;
|
||||
const containerTop = el.getBoundingClientRect().top;
|
||||
const top = el.scrollTop + sectionTop - containerTop - heroH - 41;
|
||||
const top = el.scrollTop + sectionTop - containerTop - currentHeroH - 41;
|
||||
el.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
|
||||
}
|
||||
void navigate({
|
||||
search: (prev: Record<string, string | undefined>) => ({ ...prev, tab: id }),
|
||||
replace: true,
|
||||
});
|
||||
}, [sectionRefs, scrollRef, heroRef, navigate]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sectionRefs, scrollRef, heroRef, heroH, navigate]);
|
||||
|
||||
const TAB_LABELS: Record<SectionId, string> = {
|
||||
overview: t('projects.overview'),
|
||||
timeline: t('projects.projectTimeline'),
|
||||
tasks: t('projects.tasks'),
|
||||
notes: t('projects.notes'),
|
||||
files: t('projects.folder.title'),
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="sticky z-20 backdrop-blur-md border-b border-border/40"
|
||||
style={{ top: 'var(--hero-h)' }}
|
||||
style={{ top: heroH }}
|
||||
>
|
||||
<div className="mx-auto max-w-6xl px-8 flex gap-0">
|
||||
{SECTIONS.map((id) => (
|
||||
|
||||
127
src/renderer/components/projects/folder/FilesSection.tsx
Normal file
127
src/renderer/components/projects/folder/FilesSection.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { addMonths, startOfMonth, format } from 'date-fns';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
} from '@/components/ui/empty';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { usePlatform } from '@/lib/platform';
|
||||
import { FolderLinkCard } from './FolderLinkCard';
|
||||
import { FolderFileList } from './FolderFileList';
|
||||
import { FolderUnlinkDialog } from './FolderUnlinkDialog';
|
||||
|
||||
interface FilesSectionProps {
|
||||
projectId: string;
|
||||
folderPath: string | null;
|
||||
totalFiles: number;
|
||||
lastScannedAt: number | null;
|
||||
scanStatus: 'idle' | 'scanning' | 'error' | null;
|
||||
}
|
||||
|
||||
export function FilesSection({
|
||||
projectId,
|
||||
folderPath,
|
||||
totalFiles,
|
||||
lastScannedAt,
|
||||
scanStatus,
|
||||
}: FilesSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { notify, notifyError } = useNotify();
|
||||
const platform = usePlatform();
|
||||
const [unlinkOpen, setUnlinkOpen] = useState(false);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const chooseFolder = trpc.projectFolders.chooseFolder.useMutation();
|
||||
const link = trpc.projectFolders.link.useMutation({
|
||||
onSuccess: () => utils.projects.get.invalidate({ id: projectId }),
|
||||
onError: (err) => notifyError('errors.error', err),
|
||||
});
|
||||
const startScan = trpc.projectFolders.startScan.useMutation();
|
||||
|
||||
/** Parse a QUOTA error message from the tRPC FORBIDDEN payload. */
|
||||
function handleScanError(err: { message?: string }): void {
|
||||
const msg = err.message ?? '';
|
||||
if (msg.startsWith('QUOTA:max_files:')) {
|
||||
// Backend message format: "Folder has X files; tier 'free' allows max Y."
|
||||
// Extract tier and max-count to pass to the i18n key.
|
||||
const detail = msg.slice('QUOTA:max_files:'.length);
|
||||
const tierMatch = detail.match(/tier '([^']+)'/);
|
||||
const countMatch = detail.match(/allows max (\d+)/);
|
||||
const tier = tierMatch?.[1] ?? 'your';
|
||||
const count = countMatch ? parseInt(countMatch[1], 10) : 0;
|
||||
notify('error', 'projects.folder.errors.tooBig', { values: { tier, count } });
|
||||
return;
|
||||
}
|
||||
if (msg.startsWith('QUOTA:monthly_tokens:')) {
|
||||
// Compute first day of next month as the reset date.
|
||||
const resetDate = format(startOfMonth(addMonths(new Date(), 1)), 'PP');
|
||||
notify('error', 'projects.folder.errors.monthlyExhausted', { values: { date: resetDate } });
|
||||
return;
|
||||
}
|
||||
notifyError('errors.error', err);
|
||||
}
|
||||
|
||||
const handleChoose = async () => {
|
||||
const chosen = await chooseFolder.mutateAsync();
|
||||
if (chosen) {
|
||||
await link.mutateAsync({ projectId, folderPath: chosen });
|
||||
// Kick first scan (fire-and-forget — progress shown via getStatus polling).
|
||||
// Quota errors are caught here so we can show localised toasts.
|
||||
startScan.mutate({ projectId }, { onError: handleScanError });
|
||||
}
|
||||
};
|
||||
|
||||
if (!platform.isElectron) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground p-6 text-center">
|
||||
{t('projects.folder.webOnlyTooltip')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!folderPath) {
|
||||
return (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia>
|
||||
<Sparkles />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{t('projects.folder.empty.title')}</EmptyTitle>
|
||||
<EmptyDescription>{t('projects.folder.empty.description')}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<Button
|
||||
onClick={handleChoose}
|
||||
disabled={chooseFolder.isPending || link.isPending}
|
||||
>
|
||||
{t('projects.folder.empty.cta')}
|
||||
</Button>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<FolderLinkCard
|
||||
projectId={projectId}
|
||||
folderPath={folderPath}
|
||||
totalFiles={totalFiles}
|
||||
lastScannedAt={lastScannedAt}
|
||||
scanStatus={scanStatus}
|
||||
onUnlinkRequested={() => setUnlinkOpen(true)}
|
||||
/>
|
||||
<FolderFileList projectId={projectId} />
|
||||
<FolderUnlinkDialog
|
||||
projectId={projectId}
|
||||
open={unlinkOpen}
|
||||
onOpenChange={setUnlinkOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
src/renderer/components/projects/folder/FolderChip.tsx
Normal file
83
src/renderer/components/projects/folder/FolderChip.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Folder, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface FolderChipProps {
|
||||
projectId: string;
|
||||
folderPath: string | null;
|
||||
totalFiles: number;
|
||||
lastScannedAt: number | null;
|
||||
scanStatus: 'idle' | 'scanning' | 'error' | null;
|
||||
scanProgress?: { processed: number; total: number } | null;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function FolderChip({
|
||||
folderPath,
|
||||
totalFiles,
|
||||
lastScannedAt,
|
||||
scanStatus,
|
||||
scanProgress,
|
||||
onClick,
|
||||
}: FolderChipProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!folderPath) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border border-dashed border-border text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{t('projects.folder.linkCta')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (scanStatus === 'scanning' && scanProgress) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-900 dark:text-amber-100"
|
||||
>
|
||||
<Folder className="h-3 w-3 animate-pulse" />
|
||||
{t('projects.folder.scanning', {
|
||||
processed: scanProgress.processed,
|
||||
total: scanProgress.total,
|
||||
})}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (scanStatus === 'error') {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-200"
|
||||
>
|
||||
<Folder className="h-3 w-3" />
|
||||
{t('projects.folder.scanFailed')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const relative = lastScannedAt
|
||||
? formatDistanceToNow(new Date(lastScannedAt), { addSuffix: true })
|
||||
: '—';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium',
|
||||
'bg-[#fbc881]/20 hover:bg-[#fbc881]/30 transition-colors',
|
||||
)}
|
||||
>
|
||||
<Folder className="h-3 w-3" />
|
||||
<span>{t('projects.folder.filesCount', { count: totalFiles })}</span>
|
||||
<span className="opacity-60">·</span>
|
||||
<span className="opacity-70">{relative}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
70
src/renderer/components/projects/folder/FolderFileList.tsx
Normal file
70
src/renderer/components/projects/folder/FolderFileList.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FolderFileListProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
type Filter = 'all' | 'text' | 'image' | 'pdf' | 'docx';
|
||||
|
||||
const FILTERS: Filter[] = ['all', 'text', 'image', 'pdf', 'docx'];
|
||||
|
||||
export function FolderFileList({ projectId }: FolderFileListProps) {
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
const { data, isLoading } = trpc.projectFolders.listFiles.useQuery({ projectId });
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!data) return [];
|
||||
if (filter === 'all') return data;
|
||||
return data.filter((f) => f.kind === filter);
|
||||
}, [data, filter]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Skeleton key={i} className="h-12" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-2 mb-3 text-xs">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={cn(
|
||||
'px-2.5 py-1 rounded-full border border-border',
|
||||
filter === f
|
||||
? 'bg-foreground text-background'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{items.map((f) => (
|
||||
<li
|
||||
key={f.id}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-2 border border-border bg-background/50',
|
||||
f.kind === 'skipped' && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<div className="font-mono text-xs">{f.relativePath}</div>
|
||||
{f.summary && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{f.summary}</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
src/renderer/components/projects/folder/FolderLinkCard.tsx
Normal file
65
src/renderer/components/projects/folder/FolderLinkCard.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Folder } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface FolderLinkCardProps {
|
||||
projectId: string;
|
||||
folderPath: string;
|
||||
totalFiles: number;
|
||||
lastScannedAt: number | null;
|
||||
scanStatus: 'idle' | 'scanning' | 'error' | null;
|
||||
onUnlinkRequested: () => void;
|
||||
}
|
||||
|
||||
export function FolderLinkCard({
|
||||
projectId,
|
||||
folderPath,
|
||||
totalFiles,
|
||||
lastScannedAt,
|
||||
scanStatus,
|
||||
onUnlinkRequested,
|
||||
}: FolderLinkCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { notifyError } = useNotify();
|
||||
const utils = trpc.useUtils();
|
||||
const startScan = trpc.projectFolders.startScan.useMutation({
|
||||
onSuccess: () => utils.projects.get.invalidate({ id: projectId }),
|
||||
onError: (err) => notifyError('errors.error', err),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Folder className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-mono text-xs text-muted-foreground truncate">{folderPath}</div>
|
||||
<div className="text-xs text-muted-foreground/70 mt-0.5">
|
||||
{t('projects.folder.filesCount', { count: totalFiles })}
|
||||
{lastScannedAt && (
|
||||
<>
|
||||
{' · '}
|
||||
{t('projects.folder.lastScanned', {
|
||||
relative: formatDistanceToNow(new Date(lastScannedAt), { addSuffix: true }),
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={scanStatus === 'scanning' || startScan.isPending}
|
||||
onClick={() => startScan.mutate({ projectId })}
|
||||
>
|
||||
{t('projects.folder.rescan')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onUnlinkRequested}>
|
||||
{t('projects.folder.unlink')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
|
||||
interface FolderUnlinkDialogProps {
|
||||
projectId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function FolderUnlinkDialog({
|
||||
projectId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: FolderUnlinkDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { notifyError } = useNotify();
|
||||
const utils = trpc.useUtils();
|
||||
const unlink = trpc.projectFolders.unlink.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.projects.get.invalidate({ id: projectId });
|
||||
utils.projectFolders.listFiles.invalidate({ projectId });
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => notifyError('errors.error', err),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('projects.folder.unlink')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('projects.deleteProjectDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => unlink.mutate({ projectId })}
|
||||
disabled={unlink.isPending}
|
||||
>
|
||||
{unlink.isPending ? t('common.deleting') : t('projects.folder.unlink')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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,102 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { CloudAgentConfig } from '../../../../shared/api-types';
|
||||
import { DATA_TYPES, SCHEDULE_OPTIONS } from './types';
|
||||
|
||||
export function CloudAgentConfigPanel({
|
||||
agent,
|
||||
onOpenJourney,
|
||||
}: {
|
||||
agent: CloudAgentConfig & { agentType: 'cloud' };
|
||||
onOpenJourney: () => void;
|
||||
}) {
|
||||
const utils = trpc.useUtils();
|
||||
const updateMutation = trpc.agent.cloud.update.useMutation();
|
||||
|
||||
const [dataTypes, setDataTypes] = useState<string[]>(agent.dataTypes ?? []);
|
||||
const [schedule, setSchedule] = useState(agent.scheduleCron ?? '0 * * * *');
|
||||
const { notify, notifyError } = useNotify();
|
||||
|
||||
function toggleDataType(type: string) {
|
||||
setDataTypes(prev =>
|
||||
prev.includes(type) ? prev.filter(t => t !== type) : [...prev, type],
|
||||
);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
updateMutation.mutate(
|
||||
{ id: agent.id, dataTypes, scheduleCron: schedule },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notify('success', 'toast.agent.updated');
|
||||
void utils.agent.cloud.list.invalidate();
|
||||
},
|
||||
onError: (err) => notifyError('toast.agent.updateError', err),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Provider info */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="capitalize">{agent.provider}</Badge>
|
||||
<span className="text-xs text-muted-foreground">Connected service</span>
|
||||
</div>
|
||||
|
||||
{/* Data types */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-2">What to extract</label>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{DATA_TYPES.map(type => (
|
||||
<label key={type} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={dataTypes.includes(type)}
|
||||
onCheckedChange={() => toggleDataType(type)}
|
||||
/>
|
||||
<span className="capitalize">{type}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedule */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-2">Schedule</label>
|
||||
<Select value={schedule} onValueChange={setSchedule}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCHEDULE_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* AI Prompt + Save */}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<Button size="sm" variant="outline" onClick={onOpenJourney}>
|
||||
<Sparkles className="size-3.5 mr-1.5" />
|
||||
Customize AI prompt
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={updateMutation.isPending}>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
src/renderer/components/settings/CloudScoutConfigPanel.tsx
Normal file
161
src/renderer/components/settings/CloudScoutConfigPanel.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Mail, X, Plus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import type { CloudScoutConfig } from '../../../shared/api-types';
|
||||
|
||||
export function CloudScoutConfigPanel({
|
||||
scout,
|
||||
}: {
|
||||
scout: CloudScoutConfig & { scoutType: 'cloud' };
|
||||
onOpenJourney: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const utils = trpc.useUtils();
|
||||
const updateMutation = trpc.scout.cloud.update.useMutation();
|
||||
const startOAuth = trpc.scout.cloud.startGmailOAuth.useMutation();
|
||||
const completeOAuth = trpc.scout.cloud.completeGmailOAuth.useMutation();
|
||||
const disconnect = trpc.scout.cloud.disconnectGmail.useMutation();
|
||||
const { notify, notifyError } = useNotify();
|
||||
|
||||
const [focus, setFocus] = useState(scout.promptTemplate ?? '');
|
||||
const [autoTrash, setAutoTrash] = useState(scout.autoTrashSpam ?? false);
|
||||
const [labels, setLabels] = useState<string[]>(scout.filterConfig?.labels ?? []);
|
||||
const [senders, setSenders] = useState<string[]>(scout.filterConfig?.senders ?? []);
|
||||
const [senderInput, setSenderInput] = useState('');
|
||||
|
||||
const labelsQuery = trpc.scout.cloud.gmailLabels.useQuery(
|
||||
{ scoutId: scout.id },
|
||||
{ enabled: !!scout.oauthConnected },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const electronAI = (window as unknown as { electronAI?: { onScoutGmailOAuthCallback?: (cb: (d: { code: string; state: string }) => void) => (() => void) } }).electronAI;
|
||||
if (!electronAI?.onScoutGmailOAuthCallback) return;
|
||||
const off = electronAI.onScoutGmailOAuthCallback(async ({ code, state }) => {
|
||||
try {
|
||||
await completeOAuth.mutateAsync({ code, state });
|
||||
notify('success', 'toast.scout.gmailConnected');
|
||||
void utils.scout.cloud.list.invalidate();
|
||||
} catch (err) {
|
||||
notifyError('toast.scout.updateError', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
});
|
||||
return () => { off?.(); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function toggleLabel(id: string) {
|
||||
setLabels(prev => prev.includes(id) ? prev.filter(l => l !== id) : [...prev, id]);
|
||||
}
|
||||
function addSender() {
|
||||
const v = senderInput.trim();
|
||||
if (v && !senders.includes(v)) setSenders(prev => [...prev, v]);
|
||||
setSenderInput('');
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
updateMutation.mutate(
|
||||
{ id: scout.id, promptTemplate: focus, autoTrashSpam: autoTrash, filterConfig: { labels, senders } },
|
||||
{
|
||||
onSuccess: () => { notify('success', 'toast.scout.updated'); void utils.scout.cloud.list.invalidate(); },
|
||||
onError: (err) => notifyError('toast.scout.updateError', err),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleDisconnect() {
|
||||
disconnect.mutate({ scoutId: scout.id }, {
|
||||
onSuccess: () => { notify('warning', 'toast.scout.updated'); void utils.scout.cloud.list.invalidate(); },
|
||||
onError: (err) => notifyError('toast.scout.updateError', err),
|
||||
});
|
||||
}
|
||||
|
||||
const connected = !!scout.oauthConnected;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="capitalize">{scout.provider}</Badge>
|
||||
</div>
|
||||
|
||||
{/* Connection status */}
|
||||
{connected ? (
|
||||
<div className="flex items-center gap-3 rounded-md border px-3 py-2">
|
||||
<Mail className="size-4 text-muted-foreground" />
|
||||
<span className="text-xs flex-1">{t('scouts.connectedAs')} <span className="font-medium">{scout.gmailAddress ?? '—'}</span></span>
|
||||
<Button size="sm" variant="outline" onClick={() => startOAuth.mutate({ scoutId: scout.id })}>{t('scouts.reconnect')}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleDisconnect} disabled={disconnect.isPending}>{t('scouts.disconnect')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 dark:border-amber-800 dark:bg-amber-950/30">
|
||||
<span className="text-xs text-amber-700 dark:text-amber-400 flex-1">{t('scouts.gmailAccessRequired')}</span>
|
||||
<Button size="sm" variant="outline" onClick={() => startOAuth.mutate({ scoutId: scout.id })} disabled={startOAuth.isPending}>
|
||||
{t('scouts.connectGmail')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Focus */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-2">{t('scouts.focusLabel')}</label>
|
||||
<Input value={focus} onChange={(e) => setFocus(e.target.value)} placeholder={t('scouts.focusPlaceholder')} />
|
||||
</div>
|
||||
|
||||
{/* Filter — labels */}
|
||||
{connected && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-2">{t('scouts.filterLabels')}</label>
|
||||
{labelsQuery.isPending && <p className="text-xs text-muted-foreground">Loading labels…</p>}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{(labelsQuery.data ?? []).map(lbl => (
|
||||
<label key={lbl.id} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={labels.includes(lbl.id)} onCheckedChange={() => toggleLabel(lbl.id)} />
|
||||
<span>{lbl.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter — senders */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-2">{t('scouts.filterSenders')}</label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={senderInput} onChange={(e) => setSenderInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSender(); } }}
|
||||
placeholder={t('scouts.filterSendersPlaceholder')} />
|
||||
<Button size="sm" variant="outline" onClick={addSender}><Plus className="size-3.5" /></Button>
|
||||
</div>
|
||||
{senders.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{senders.map(s => (
|
||||
<span key={s} className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-xs">
|
||||
{s}<button onClick={() => setSenders(prev => prev.filter(x => x !== s))}><X className="size-3" /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto-trash */}
|
||||
<div className="flex items-center justify-between rounded-lg border px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t('scouts.autoTrashSpam')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('scouts.autoTrashHint')}</p>
|
||||
</div>
|
||||
<Switch checked={autoTrash} onCheckedChange={setAutoTrash} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end pt-1">
|
||||
<Button size="sm" onClick={handleSave} disabled={updateMutation.isPending}>{t('common.save')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
210
src/renderer/components/settings/CloudScoutCreationFlow.tsx
Normal file
210
src/renderer/components/settings/CloudScoutCreationFlow.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Mail, X, Plus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import type { AgentCatalogItem } from '../../../shared/api-types';
|
||||
|
||||
export function CloudScoutCreationFlow({
|
||||
template,
|
||||
onCancel,
|
||||
onCreated,
|
||||
}: {
|
||||
template: AgentCatalogItem;
|
||||
onCancel: () => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { notify, notifyError } = useNotify();
|
||||
|
||||
const startOAuthDraft = trpc.scout.cloud.startGmailOAuthDraft.useMutation();
|
||||
const completeOAuth = trpc.scout.cloud.completeGmailOAuth.useMutation();
|
||||
const finalizeMutation = trpc.scout.cloud.finalizeCloudScout.useMutation();
|
||||
|
||||
const [step, setStep] = useState<'basics' | 'filter'>('basics');
|
||||
const [name, setName] = useState(template.name);
|
||||
const [focus, setFocus] = useState('');
|
||||
const [autoTrash, setAutoTrash] = useState(false);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [labels, setLabels] = useState<string[]>([]);
|
||||
const [senderInput, setSenderInput] = useState('');
|
||||
const [senders, setSenders] = useState<string[]>([]);
|
||||
|
||||
const labelsQuery = trpc.scout.cloud.gmailSessionLabels.useQuery(
|
||||
{ session: sessionId ?? '' },
|
||||
{ enabled: step === 'filter' && !!sessionId },
|
||||
);
|
||||
|
||||
// OAuth deep-link callback → complete, capture the session, advance to filter.
|
||||
useEffect(() => {
|
||||
const electronAI = (window as unknown as { electronAI?: { onScoutGmailOAuthCallback?: (cb: (d: { code: string; state: string }) => void) => (() => void) } }).electronAI;
|
||||
if (!electronAI?.onScoutGmailOAuthCallback) return;
|
||||
const off = electronAI.onScoutGmailOAuthCallback(async ({ code, state }) => {
|
||||
try {
|
||||
const res = await completeOAuth.mutateAsync({ code, state });
|
||||
// sessionId is only present for create-mode flows; reconnect returns null.
|
||||
if (res.sessionId) {
|
||||
setSessionId(res.sessionId);
|
||||
setConnecting(false);
|
||||
setStep('filter');
|
||||
notify('success', 'toast.scout.gmailConnected');
|
||||
}
|
||||
} catch (err) {
|
||||
setConnecting(false);
|
||||
notifyError('toast.scout.createError', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
});
|
||||
return () => { off?.(); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function handleConnect() {
|
||||
if (!name.trim()) { setError(t('scouts.nameRequired')); return; }
|
||||
setError('');
|
||||
setConnecting(true);
|
||||
try {
|
||||
// No scout is created here — the draft is held in the server-side session.
|
||||
await startOAuthDraft.mutateAsync({
|
||||
name,
|
||||
promptTemplate: focus,
|
||||
autoTrashSpam: autoTrash,
|
||||
});
|
||||
// Wait for the deep-link callback (handled in the effect).
|
||||
} catch (err) {
|
||||
setConnecting(false);
|
||||
setError(err instanceof Error ? err.message : 'Failed to connect');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLabel(id: string) {
|
||||
setLabels(prev => prev.includes(id) ? prev.filter(l => l !== id) : [...prev, id]);
|
||||
}
|
||||
|
||||
function addSender() {
|
||||
const v = senderInput.trim();
|
||||
if (v && !senders.includes(v)) setSenders(prev => [...prev, v]);
|
||||
setSenderInput('');
|
||||
}
|
||||
|
||||
async function handleFinish() {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const res = await finalizeMutation.mutateAsync({
|
||||
session: sessionId,
|
||||
filterConfig: { labels, senders },
|
||||
});
|
||||
if (res.error || !res.data) throw new Error(res.error ?? 'finalize failed');
|
||||
notify('success', 'toast.scout.created');
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
notifyError('toast.scout.createError', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
if (step === 'basics') {
|
||||
return (
|
||||
<div className="flex flex-col gap-7 w-full">
|
||||
<div className="pb-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 2 of 3</p>
|
||||
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
|
||||
Configure<br />
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="text-muted-foreground/50 bg-transparent outline-none border-none w-full placeholder:text-muted-foreground/30 caret-primary"
|
||||
placeholder={t('scouts.namePlaceholder')}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">{t('scouts.gmailBasicsHint')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">{t('scouts.focusLabel')}</label>
|
||||
<Input value={focus} onChange={(e) => setFocus(e.target.value)} placeholder={t('scouts.focusPlaceholder')} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t('scouts.autoTrashSpam')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('scouts.autoTrashHint')}</p>
|
||||
</div>
|
||||
<Switch checked={autoTrash} onCheckedChange={setAutoTrash} />
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<Button variant="outline" size="sm" onClick={onCancel}>Cancel</Button>
|
||||
<Button size="sm" onClick={handleConnect} disabled={connecting}>
|
||||
<Mail className="size-3.5 mr-1.5" />
|
||||
{connecting ? t('scouts.connecting') : t('scouts.connectGmail')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// step === 'filter'
|
||||
return (
|
||||
<div className="flex flex-col gap-7 w-full">
|
||||
<div className="pb-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 3 of 3</p>
|
||||
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
|
||||
Narrow the<br />
|
||||
<span className="text-muted-foreground/50">emails it watches.</span>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">{t('scouts.filterHint')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">{t('scouts.filterLabels')}</label>
|
||||
{labelsQuery.isPending && <p className="text-xs text-muted-foreground">Loading labels…</p>}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{(labelsQuery.data ?? []).map(lbl => (
|
||||
<label key={lbl.id} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={labels.includes(lbl.id)} onCheckedChange={() => toggleLabel(lbl.id)} />
|
||||
<span>{lbl.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">{t('scouts.filterSenders')}</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={senderInput}
|
||||
onChange={(e) => setSenderInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSender(); } }}
|
||||
placeholder={t('scouts.filterSendersPlaceholder')}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={addSender}><Plus className="size-3.5" /></Button>
|
||||
</div>
|
||||
{senders.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{senders.map(s => (
|
||||
<span key={s} className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-xs">
|
||||
{s}
|
||||
<button onClick={() => setSenders(prev => prev.filter(x => x !== s))}><X className="size-3" /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-2">{t('scouts.watchAllInbox')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<Button variant="outline" size="sm" onClick={handleFinish}>{t('scouts.skipFilter')}</Button>
|
||||
<Button size="sm" onClick={handleFinish} disabled={finalizeMutation.isPending}>{t('scouts.finish')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Bot, FolderOpen, X, Sparkles } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { AgentCatalogItem } from '../../../../shared/api-types';
|
||||
import { DATA_TYPE_CONFIG, SCHEDULE_OPTIONS } from './types';
|
||||
import { TemplateSelectCard } from './TemplateSelectCard';
|
||||
import { PromptBuilderChat } from './PromptBuilderChat';
|
||||
|
||||
export function InlineAgentCreationStepper({
|
||||
catalog,
|
||||
isLoadingCatalog,
|
||||
onCancel,
|
||||
onCreated,
|
||||
}: {
|
||||
catalog: AgentCatalogItem[];
|
||||
isLoadingCatalog: boolean;
|
||||
onCancel: () => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const createLocalMutation = trpc.agent.local.create.useMutation();
|
||||
const createCloudMutation = trpc.agent.cloud.create.useMutation();
|
||||
const { notify, notifyError } = useNotify();
|
||||
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<AgentCatalogItem | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [directory, setDirectory] = useState('');
|
||||
const [promptDialogOpen, setPromptDialogOpen] = useState(false);
|
||||
const [dataTypes, setDataTypes] = useState<string[]>([]);
|
||||
const [schedule, setSchedule] = useState('0 * * * *');
|
||||
const [promptTemplate, setPromptTemplate] = useState('');
|
||||
const [agentConfig, setAgentConfig] = useState<Record<string, unknown> | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const isSubmitting = createLocalMutation.isPending || createCloudMutation.isPending;
|
||||
|
||||
function applyTemplateDefaults(item: AgentCatalogItem) {
|
||||
setSelectedTemplate(item);
|
||||
setName(item.name);
|
||||
setDirectory('');
|
||||
setDataTypes((item.supportedDataTypes ?? []).slice(0, 2));
|
||||
setSchedule('0 * * * *');
|
||||
setPromptTemplate('');
|
||||
setAgentConfig(null);
|
||||
setError('');
|
||||
setStep(2);
|
||||
}
|
||||
|
||||
async function pickDirectory() {
|
||||
try {
|
||||
const result = await window.electronDialog.showOpenDialog({
|
||||
properties: ['openDirectory'],
|
||||
title: 'Select directory for agent to watch',
|
||||
});
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
setDirectory(result.filePaths[0]!);
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDataType(type: string) {
|
||||
setDataTypes(prev =>
|
||||
prev.includes(type) ? prev.filter(t => t !== type) : [...prev, type],
|
||||
);
|
||||
}
|
||||
|
||||
function nextFromConfig() {
|
||||
if (!selectedTemplate) return;
|
||||
if (!name.trim()) {
|
||||
setError('Agent name is required.');
|
||||
return;
|
||||
}
|
||||
if (selectedTemplate.type === 'local_directory' && !directory) {
|
||||
setError('Select a directory.');
|
||||
return;
|
||||
}
|
||||
if (dataTypes.length === 0) {
|
||||
setError('Select at least one data type.');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setStep(3);
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
if (!selectedTemplate) return;
|
||||
|
||||
setError('');
|
||||
|
||||
if (selectedTemplate.type === 'local_directory') {
|
||||
createLocalMutation.mutate(
|
||||
{
|
||||
name,
|
||||
directory,
|
||||
dataTypes,
|
||||
scheduleCron: schedule,
|
||||
agentConfig: agentConfig ?? null,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notify('success', 'toast.agent.created');
|
||||
onCreated();
|
||||
},
|
||||
onError: (err) => {
|
||||
notifyError('toast.agent.createError', err);
|
||||
setError(err.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
createCloudMutation.mutate(
|
||||
{
|
||||
name,
|
||||
provider: selectedTemplate.provider as 'gmail' | 'teams' | 'outlook',
|
||||
dataTypes,
|
||||
scheduleCron: schedule,
|
||||
promptTemplate,
|
||||
filterConfig: {},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notify('success', 'toast.agent.created');
|
||||
onCreated();
|
||||
},
|
||||
onError: (err) => {
|
||||
notifyError('toast.agent.createError', err);
|
||||
setError(err.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 w-full">
|
||||
|
||||
{step === 1 && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="pb-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 1 of 3</p>
|
||||
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
|
||||
Choose your<br />
|
||||
<span className="text-muted-foreground/50">starting template.</span>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">Pick a starting point — you can customize everything before the agent goes live.</p>
|
||||
</div>
|
||||
|
||||
{isLoadingCatalog && (
|
||||
<div className="rounded-lg border border-dashed px-4 py-10 text-sm text-muted-foreground text-center bg-muted/20">
|
||||
Loading templates...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingCatalog && catalog.length === 0 && (
|
||||
<div className="rounded-xl border border-dashed px-6 py-10 text-center">
|
||||
<Bot className="size-8 mx-auto mb-3 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">No templates available yet. Add your server URL in Account settings, then try again.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{catalog.map((item) => (
|
||||
<TemplateSelectCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
selected={selectedTemplate?.id === item.id}
|
||||
onSelect={() => applyTemplateDefaults(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && selectedTemplate && (
|
||||
<div className="flex flex-col gap-7">
|
||||
<div className="pb-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 2 of 3</p>
|
||||
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
|
||||
Configure<br />
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="text-muted-foreground/50 bg-transparent outline-none border-none w-full placeholder:text-muted-foreground/30 caret-primary"
|
||||
placeholder="agent name."
|
||||
spellCheck={false}
|
||||
/>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">
|
||||
{selectedTemplate.type === 'local_directory'
|
||||
? 'Point it at your files, pick what to extract, and set the run schedule.'
|
||||
: 'Pick what to extract and set the run schedule.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Local directory: path picker */}
|
||||
{selectedTemplate.type === 'local_directory' && (
|
||||
<div>
|
||||
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">Directory</label>
|
||||
{directory && (
|
||||
<div className="flex items-center gap-2 bg-muted/40 rounded-lg px-3 py-2 text-xs font-mono mb-2">
|
||||
<FolderOpen className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="flex-1 truncate">{directory}</span>
|
||||
<button onClick={() => setDirectory('')} className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={pickDirectory}>
|
||||
<FolderOpen className="size-3.5 mr-1.5" />
|
||||
{directory ? 'Change directory' : 'Select directory'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cloud: sign-in notice */}
|
||||
{selectedTemplate.type !== 'local_directory' && (
|
||||
<div className="rounded-xl border border-dashed px-4 py-3 text-sm text-muted-foreground">
|
||||
After creating this agent, you'll be asked to sign in to <span className="font-medium text-foreground capitalize">{selectedTemplate.provider}</span> and grant read access.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* What to extract */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-3">What to extract</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DATA_TYPE_CONFIG.map(({ value, label, Icon }) => {
|
||||
const active = dataTypes.includes(value);
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => toggleDataType(value)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 px-3.5 py-2 rounded-xl border text-sm font-medium transition-all duration-150',
|
||||
active
|
||||
? 'bg-foreground text-background border-foreground'
|
||||
: 'bg-background text-muted-foreground border-border/60 hover:border-border hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Batch interval */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">Batch interval</label>
|
||||
<Select value={schedule} onValueChange={setSchedule}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCHEDULE_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Extraction prompt builder */}
|
||||
{(() => {
|
||||
const unlocked = (selectedTemplate.type !== 'local_directory' || !!directory) && dataTypes.length > 0;
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
variant={promptTemplate || agentConfig ? 'outline' : 'default'}
|
||||
size="sm"
|
||||
disabled={!unlocked}
|
||||
onClick={() => setPromptDialogOpen(true)}
|
||||
>
|
||||
<Sparkles className="size-3.5 mr-1.5" />
|
||||
{promptTemplate || agentConfig ? 'Edit extraction prompt' : 'Build extraction prompt'}
|
||||
</Button>
|
||||
|
||||
<Dialog open={promptDialogOpen} onOpenChange={setPromptDialogOpen}>
|
||||
<DialogContent showCloseButton={false} className="w-[95vw] max-w-[1100px] h-[75vh] max-h-[780px] flex flex-col gap-0 p-0 overflow-hidden">
|
||||
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
<PromptBuilderChat
|
||||
autoStart
|
||||
agentType={selectedTemplate.type === 'local_directory' ? 'local_directory' : selectedTemplate.provider as 'gmail' | 'teams' | 'outlook'}
|
||||
dataTypes={dataTypes}
|
||||
directory={selectedTemplate.type === 'local_directory' ? directory : undefined}
|
||||
onPromptUpdate={(p) => setPromptTemplate(p)}
|
||||
onConfigUpdate={(c) => setAgentConfig(c)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 px-5 py-4 border-t shrink-0">
|
||||
<Button variant="outline" size="sm" onClick={() => setPromptDialogOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setPromptDialogOpen(false)}>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && selectedTemplate && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="pb-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 3 of 3</p>
|
||||
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
|
||||
Review and<br />
|
||||
<span className="text-muted-foreground/50">create your agent.</span>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">Everything looks good? Hit create and your agent will start running on schedule.</p>
|
||||
</div>
|
||||
<Card className="rounded-xl gap-0 py-0 shadow-none border-border/70">
|
||||
<CardContent className="p-5 flex flex-col gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Template</p>
|
||||
<p className="text-sm font-medium">{selectedTemplate.name}</p>
|
||||
</div>
|
||||
<div className="border rounded-lg px-4 py-3 grid gap-2 text-sm bg-background">
|
||||
<p><span className="text-muted-foreground">Name:</span> {name}</p>
|
||||
<p><span className="text-muted-foreground">Type:</span> {selectedTemplate.type}</p>
|
||||
<p><span className="text-muted-foreground">Data types:</span> {dataTypes.join(', ') || 'None'}</p>
|
||||
<p><span className="text-muted-foreground">Schedule:</span> {SCHEDULE_OPTIONS.find(s => s.value === schedule)?.label ?? schedule}</p>
|
||||
{selectedTemplate.type === 'local_directory' && directory && (
|
||||
<p><span className="text-muted-foreground">Directory:</span> {directory}</p>
|
||||
)}
|
||||
{(selectedTemplate.type === 'local_directory' ? agentConfig : promptTemplate) && (
|
||||
<p><span className="text-muted-foreground">Extraction config:</span> Added</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setError('');
|
||||
if (step === 1) {
|
||||
onCancel();
|
||||
} else {
|
||||
setStep(prev => (prev - 1) as 1 | 2 | 3);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{step === 1 ? 'Cancel' : 'Back'}
|
||||
</Button>
|
||||
|
||||
{step === 2 && (
|
||||
<Button size="sm" onClick={nextFromConfig}>
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<Button size="sm" onClick={handleCreate} disabled={isSubmitting}>
|
||||
Create agent now
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState } from 'react';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { AgentCatalogItem } from '../../../shared/api-types';
|
||||
import { TemplateSelectCard } from './TemplateSelectCard';
|
||||
import { LocalScoutCreationFlow } from './LocalScoutCreationFlow';
|
||||
import { CloudScoutCreationFlow } from './CloudScoutCreationFlow';
|
||||
|
||||
export function InlineScoutCreationStepper({
|
||||
catalog,
|
||||
isLoadingCatalog,
|
||||
onCancel,
|
||||
onCreated,
|
||||
}: {
|
||||
catalog: AgentCatalogItem[];
|
||||
isLoadingCatalog: boolean;
|
||||
onCancel: () => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<AgentCatalogItem | null>(null);
|
||||
|
||||
if (selectedTemplate) {
|
||||
return selectedTemplate.type === 'local_directory'
|
||||
? <LocalScoutCreationFlow template={selectedTemplate} onCancel={() => setSelectedTemplate(null)} onCreated={onCreated} />
|
||||
: <CloudScoutCreationFlow template={selectedTemplate} onCancel={() => setSelectedTemplate(null)} onCreated={onCreated} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 w-full">
|
||||
<div className="pb-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 1 of 3</p>
|
||||
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
|
||||
Choose your<br />
|
||||
<span className="text-muted-foreground/50">starting template.</span>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">Pick a starting point — you can customize everything before the scout goes live.</p>
|
||||
</div>
|
||||
|
||||
{isLoadingCatalog && (
|
||||
<div className="rounded-lg border border-dashed px-4 py-10 text-sm text-muted-foreground text-center bg-muted/20">Loading templates...</div>
|
||||
)}
|
||||
|
||||
{!isLoadingCatalog && catalog.length === 0 && (
|
||||
<div className="rounded-xl border border-dashed px-6 py-10 text-center">
|
||||
<Bot className="size-8 mx-auto mb-3 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">No templates available yet. Add your server URL in Account settings, then try again.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{catalog.map((item) => (
|
||||
<TemplateSelectCard key={item.id} item={item} selected={false} onSelect={() => setSelectedTemplate(item)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<Button variant="outline" size="sm" onClick={onCancel}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}
|
||||
206
src/renderer/components/settings/LocalScoutCreationFlow.tsx
Normal file
206
src/renderer/components/settings/LocalScoutCreationFlow.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useState } from 'react';
|
||||
import { FolderOpen, X, Sparkles } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import type { AgentCatalogItem } from '../../../shared/api-types';
|
||||
import { DATA_TYPE_CONFIG, SCHEDULE_OPTIONS } from './types';
|
||||
import { PromptBuilderChat } from './PromptBuilderChat';
|
||||
|
||||
export function LocalScoutCreationFlow({
|
||||
template,
|
||||
onCancel,
|
||||
onCreated,
|
||||
}: {
|
||||
template: AgentCatalogItem;
|
||||
onCancel: () => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const createLocalMutation = trpc.scout.local.create.useMutation();
|
||||
const { notify, notifyError } = useNotify();
|
||||
|
||||
const [step, setStep] = useState<2 | 3>(2);
|
||||
const [name, setName] = useState(template.name);
|
||||
const [directory, setDirectory] = useState('');
|
||||
const [promptDialogOpen, setPromptDialogOpen] = useState(false);
|
||||
const [dataTypes, setDataTypes] = useState<string[]>((template.supportedDataTypes ?? []).slice(0, 2));
|
||||
const [schedule, setSchedule] = useState('0 * * * *');
|
||||
const [promptTemplate, setPromptTemplate] = useState('');
|
||||
const [scoutConfig, setScoutConfig] = useState<Record<string, unknown> | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const isSubmitting = createLocalMutation.isPending;
|
||||
|
||||
async function pickDirectory() {
|
||||
try {
|
||||
const result = await window.electronDialog.showOpenDialog({
|
||||
properties: ['openDirectory'],
|
||||
title: 'Select directory for scout to watch',
|
||||
});
|
||||
if (!result.canceled && result.filePaths.length > 0) setDirectory(result.filePaths[0]!);
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
|
||||
function toggleDataType(type: string) {
|
||||
setDataTypes(prev => prev.includes(type) ? prev.filter(t => t !== type) : [...prev, type]);
|
||||
}
|
||||
|
||||
function nextFromConfig() {
|
||||
if (!name.trim()) { setError('Scout name is required.'); return; }
|
||||
if (!directory) { setError('Select a directory.'); return; }
|
||||
if (dataTypes.length === 0) { setError('Select at least one data type.'); return; }
|
||||
setError(''); setStep(3);
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
setError('');
|
||||
createLocalMutation.mutate(
|
||||
{ name, directory, dataTypes, scheduleCron: schedule, agentConfig: scoutConfig ?? null },
|
||||
{
|
||||
onSuccess: () => { notify('success', 'toast.scout.created'); onCreated(); },
|
||||
onError: (err) => { notifyError('toast.scout.createError', err); setError(err.message); },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const unlocked = !!directory && dataTypes.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-7 w-full">
|
||||
{step === 2 && (
|
||||
<>
|
||||
<div className="pb-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 2 of 3</p>
|
||||
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
|
||||
Configure<br />
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="text-muted-foreground/50 bg-transparent outline-none border-none w-full placeholder:text-muted-foreground/30 caret-primary"
|
||||
placeholder="scout name."
|
||||
spellCheck={false}
|
||||
/>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">Point it at your files, pick what to extract, and set the run schedule.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">Directory</label>
|
||||
{directory && (
|
||||
<div className="flex items-center gap-2 bg-muted/40 rounded-lg px-3 py-2 text-xs font-mono mb-2">
|
||||
<FolderOpen className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="flex-1 truncate">{directory}</span>
|
||||
<button onClick={() => setDirectory('')} className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={pickDirectory}>
|
||||
<FolderOpen className="size-3.5 mr-1.5" />
|
||||
{directory ? 'Change directory' : 'Select directory'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-3">What to extract</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DATA_TYPE_CONFIG.map(({ value, label, Icon }) => {
|
||||
const active = dataTypes.includes(value);
|
||||
return (
|
||||
<button key={value} onClick={() => toggleDataType(value)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 px-3.5 py-2 rounded-xl border text-sm font-medium transition-all duration-150',
|
||||
active ? 'bg-foreground text-background border-foreground'
|
||||
: 'bg-background text-muted-foreground border-border/60 hover:border-border hover:text-foreground',
|
||||
)}>
|
||||
<Icon className="size-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">Batch interval</label>
|
||||
<Select value={schedule} onValueChange={setSchedule}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCHEDULE_OPTIONS.map(opt => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button variant={promptTemplate || scoutConfig ? 'outline' : 'default'} size="sm" disabled={!unlocked} onClick={() => setPromptDialogOpen(true)}>
|
||||
<Sparkles className="size-3.5 mr-1.5" />
|
||||
{promptTemplate || scoutConfig ? 'Edit extraction prompt' : 'Build extraction prompt'}
|
||||
</Button>
|
||||
<Dialog open={promptDialogOpen} onOpenChange={setPromptDialogOpen}>
|
||||
<DialogContent showCloseButton={false} className="w-[95vw] max-w-[1100px] h-[75vh] max-h-[780px] flex flex-col gap-0 p-0 overflow-hidden">
|
||||
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
<PromptBuilderChat
|
||||
autoStart
|
||||
agentType="local_directory"
|
||||
dataTypes={dataTypes}
|
||||
directory={directory}
|
||||
onPromptUpdate={(p) => setPromptTemplate(p)}
|
||||
onConfigUpdate={(c) => setScoutConfig(c)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 px-5 py-4 border-t shrink-0">
|
||||
<Button variant="outline" size="sm" onClick={() => setPromptDialogOpen(false)}>Close</Button>
|
||||
<Button size="sm" onClick={() => setPromptDialogOpen(false)}>Confirm</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="pb-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 3 of 3</p>
|
||||
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
|
||||
Review and<br />
|
||||
<span className="text-muted-foreground/50">create your scout.</span>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">Everything looks good? Hit create and your scout will start running on schedule.</p>
|
||||
</div>
|
||||
<Card className="rounded-xl gap-0 py-0 shadow-none border-border/70">
|
||||
<CardContent className="p-5 flex flex-col gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Template</p>
|
||||
<p className="text-sm font-medium">{template.name}</p>
|
||||
</div>
|
||||
<div className="border rounded-lg px-4 py-3 grid gap-2 text-sm bg-background">
|
||||
<p><span className="text-muted-foreground">Name:</span> {name}</p>
|
||||
<p><span className="text-muted-foreground">Data types:</span> {dataTypes.join(', ') || 'None'}</p>
|
||||
<p><span className="text-muted-foreground">Schedule:</span> {SCHEDULE_OPTIONS.find(s => s.value === schedule)?.label ?? schedule}</p>
|
||||
<p><span className="text-muted-foreground">Directory:</span> {directory}</p>
|
||||
{scoutConfig && <p><span className="text-muted-foreground">Extraction config:</span> Added</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<Button variant="outline" size="sm" onClick={() => { setError(''); step === 2 ? onCancel() : setStep(2); }}>
|
||||
{step === 2 ? 'Cancel' : 'Back'}
|
||||
</Button>
|
||||
{step === 2 && <Button size="sm" onClick={nextFromConfig}>Next</Button>}
|
||||
{step === 3 && <Button size="sm" onClick={handleCreate} disabled={isSubmitting}>Create scout now</Button>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,10 @@ export function AgentRow({
|
||||
onOpenJourney: () => void;
|
||||
}) {
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const scheduleLabel = SCHEDULE_OPTIONS.find(s => s.value === agent.scheduleCron)?.label ?? agent.scheduleCron;
|
||||
const lastRunLabel = agent.lastRunAt ? formatTs(agent.lastRunAt) : 'Never';
|
||||
const kindLabel = agent.agentType === 'local' ? 'Local' : `Cloud · ${(agent as CloudAgentConfig).provider}`;
|
||||
const isCloud = scout.scoutType === 'cloud';
|
||||
const scheduleLabel = SCHEDULE_OPTIONS.find(s => s.value === scout.scheduleCron)?.label ?? scout.scheduleCron;
|
||||
const lastRunLabel = scout.lastRunAt ? formatTs(scout.lastRunAt) : 'Never';
|
||||
const kindLabel = scout.scoutType === 'local' ? 'Local' : `Cloud · ${(scout as CloudScoutConfig).provider}`;
|
||||
|
||||
return (
|
||||
<Card className="rounded-xl py-0 gap-0 overflow-hidden h-fit border-border/70 shadow-none">
|
||||
@@ -38,38 +39,40 @@ export function AgentRow({
|
||||
<div className="px-4 py-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold truncate">{agent.name}</p>
|
||||
<p className="text-sm font-semibold truncate">{scout.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{kindLabel}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={agent.enabled}
|
||||
checked={scout.enabled}
|
||||
onCheckedChange={onToggleEnabled}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1 text-xs">
|
||||
<span className="text-muted-foreground">Schedule</span>
|
||||
<span className="text-foreground truncate">{scheduleLabel}</span>
|
||||
<span className="text-muted-foreground">Trigger</span>
|
||||
<span className="text-foreground truncate">{isCloud ? 'Real-time' : scheduleLabel}</span>
|
||||
<span className="text-muted-foreground">Last run</span>
|
||||
<span className="text-foreground truncate">{lastRunLabel}</span>
|
||||
<span className="text-muted-foreground">Status</span>
|
||||
<span className="text-foreground">{agent.enabled ? 'Enabled' : 'Disabled'}</span>
|
||||
<span className="text-foreground">{scout.enabled ? 'Enabled' : 'Disabled'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" variant="outline" onClick={onRunNow} className="h-8">
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
Run now
|
||||
</Button>
|
||||
{!isCloud && (
|
||||
<Button size="sm" variant="outline" onClick={onRunNow} className="h-8">
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
Run now
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => setHistoryOpen(true)} className="h-8">
|
||||
<History className="size-3.5 mr-1.5" />
|
||||
History
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={onDelete} title="Delete agent" className="h-8 w-8 p-0">
|
||||
<Button size="sm" variant="ghost" onClick={onDelete} title="Delete scout" className="h-8 w-8 p-0">
|
||||
<Trash2 className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onToggleExpand} title={expanded ? 'Collapse' : 'Configure'} className="h-8 w-8 p-0">
|
||||
@@ -82,17 +85,17 @@ export function AgentRow({
|
||||
{/* Expanded config */}
|
||||
{expanded && (
|
||||
<div className="border-t px-4 py-4 bg-muted/20">
|
||||
{agent.agentType === 'local' ? (
|
||||
<LocalAgentConfigPanel agent={agent as LocalAgentConfig & { agentType: 'local' }} onOpenJourney={onOpenJourney} />
|
||||
{scout.scoutType === 'local' ? (
|
||||
<LocalScoutConfigPanel scout={scout as LocalScoutConfig & { scoutType: 'local' }} onOpenJourney={onOpenJourney} />
|
||||
) : (
|
||||
<CloudAgentConfigPanel agent={agent as CloudAgentConfig & { agentType: 'cloud' }} onOpenJourney={onOpenJourney} />
|
||||
<CloudScoutConfigPanel scout={scout as CloudScoutConfig & { scoutType: 'cloud' }} onOpenJourney={onOpenJourney} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AgentRunHistorySheet
|
||||
agentId={agent.id}
|
||||
agentName={agent.name}
|
||||
<ScoutRunHistorySheet
|
||||
scoutId={scout.id}
|
||||
scoutName={scout.name}
|
||||
open={historyOpen}
|
||||
onOpenChange={setHistoryOpen}
|
||||
/>
|
||||
@@ -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>
|
||||
180
src/renderer/components/settings/ScoutsSection.tsx
Normal file
180
src/renderer/components/settings/ScoutsSection.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Bot, Plus } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { CloudScoutConfig } from '../../../shared/api-types';
|
||||
import type { LocalScoutConfig } from './types';
|
||||
import { ScoutRow } from './ScoutRow';
|
||||
import { InlineScoutCreationStepper } from './InlineScoutCreationStepper';
|
||||
import { JourneyDialog } from './JourneyDialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function ScoutsSection({ onCreatingChange }: { onCreatingChange?: (creating: boolean) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const utils = trpc.useUtils();
|
||||
const localScoutsQuery = trpc.scout.local.list.useQuery();
|
||||
const cloudScoutsQuery = trpc.scout.cloud.list.useQuery();
|
||||
const deleteLocalMutation = trpc.scout.local.delete.useMutation();
|
||||
const deleteCloudMutation = trpc.scout.cloud.delete.useMutation();
|
||||
const updateLocalMutation = trpc.scout.local.update.useMutation();
|
||||
const updateCloudMutation = trpc.scout.cloud.update.useMutation();
|
||||
const runNowMutation = trpc.scout.runNow.useMutation();
|
||||
|
||||
const { notify, notifyError, notifyPromise } = useNotify();
|
||||
const [expandedScout, setExpandedScout] = useState<string | null>(null);
|
||||
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
|
||||
const [journeyScout, setJourneyScout] = useState<{ id: string; type: 'local' | 'cloud'; name: string; currentConfig: Record<string, unknown> | null; dataTypes: string[]; directory?: string } | null>(null);
|
||||
|
||||
const catalogQuery = trpc.scout.catalog.useQuery(undefined, {
|
||||
enabled: showTemplatePicker,
|
||||
});
|
||||
|
||||
// Notify the settings page so it can hide its section header during creation.
|
||||
useEffect(() => {
|
||||
onCreatingChange?.(showTemplatePicker);
|
||||
}, [showTemplatePicker, onCreatingChange]);
|
||||
|
||||
// Reset on unmount (e.g. switching settings sections mid-creation).
|
||||
useEffect(() => () => onCreatingChange?.(false), [onCreatingChange]);
|
||||
|
||||
const localScouts: LocalScoutConfig[] = localScoutsQuery.data ?? [];
|
||||
const cloudScouts: CloudScoutConfig[] = cloudScoutsQuery.data ?? [];
|
||||
const allScouts = [
|
||||
...localScouts.map(a => ({ ...a, scoutType: 'local' as const })),
|
||||
...cloudScouts.map(a => ({ ...a, scoutType: 'cloud' as const })),
|
||||
];
|
||||
const hasScouts = allScouts.length > 0;
|
||||
|
||||
function handleDelete(id: string, type: 'local' | 'cloud') {
|
||||
const mutation = type === 'local' ? deleteLocalMutation : deleteCloudMutation;
|
||||
mutation.mutate({ id }, {
|
||||
// The cloud/local delete procedures resolve with { success, error } and
|
||||
// never throw, so onSuccess fires even on a backend failure. Branch on
|
||||
// the returned success flag to avoid a misleading "deleted" toast.
|
||||
onSuccess: (res) => {
|
||||
if (res && 'success' in res && !res.success) {
|
||||
notifyError('toast.scout.deleteError', new Error(res.error ?? 'Delete failed'));
|
||||
return;
|
||||
}
|
||||
notify('warning', 'toast.scout.deleted');
|
||||
void utils.scout.local.list.invalidate();
|
||||
void utils.scout.cloud.list.invalidate();
|
||||
},
|
||||
onError: (err) => notifyError('toast.scout.deleteError', err),
|
||||
});
|
||||
}
|
||||
|
||||
function handleToggleEnabled(id: string, type: 'local' | 'cloud', enabled: boolean) {
|
||||
if (type === 'local') {
|
||||
updateLocalMutation.mutate({ id, enabled }, {
|
||||
onSuccess: () => void utils.scout.local.list.invalidate(),
|
||||
onError: (err) => notifyError('toast.scout.updateError', err),
|
||||
});
|
||||
} else {
|
||||
updateCloudMutation.mutate({ id, enabled }, {
|
||||
onSuccess: () => void utils.scout.cloud.list.invalidate(),
|
||||
onError: (err) => notifyError('toast.scout.updateError', err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleRunNow(id: string) {
|
||||
const promise = runNowMutation.mutateAsync({ id });
|
||||
notifyPromise(promise, { loading: 'toast.scout.runStarted', success: 'toast.scout.runStarted', error: 'toast.scout.runError' });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Empty first-run state */}
|
||||
{!hasScouts && !showTemplatePicker && (
|
||||
<div className="py-4 text-center">
|
||||
<div className="size-11 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto mb-4">
|
||||
<Bot className="size-5 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-base font-semibold">{t('scouts.noScoutsYet')}</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-md mx-auto mt-1.5">
|
||||
{t('scouts.noScoutsDescription')}
|
||||
</p>
|
||||
<Button size="sm" className="mt-5" onClick={() => setShowTemplatePicker(true)}>
|
||||
<Plus className="size-3.5 mr-1.5" />
|
||||
{t('scouts.createFirstScout')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Existing configured scouts */}
|
||||
{hasScouts && !showTemplatePicker && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">{t('scouts.yourScouts')}</h2>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowTemplatePicker(prev => !prev)}>
|
||||
<Plus className="size-3.5 mr-1.5" />
|
||||
{t('scouts.createScout')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{allScouts.map((scout) => (
|
||||
<ScoutRow
|
||||
key={scout.id}
|
||||
scout={scout}
|
||||
expanded={expandedScout === scout.id}
|
||||
onToggleExpand={() => setExpandedScout(prev => prev === scout.id ? null : scout.id)}
|
||||
onToggleEnabled={(enabled) => handleToggleEnabled(scout.id, scout.scoutType, enabled)}
|
||||
onDelete={() => handleDelete(scout.id, scout.scoutType)}
|
||||
onRunNow={() => handleRunNow(scout.id)}
|
||||
onOpenJourney={() => setJourneyScout({
|
||||
id: scout.id,
|
||||
type: scout.scoutType,
|
||||
name: scout.name,
|
||||
currentConfig: scout.scoutType === 'local' ? (scout as LocalScoutConfig).agentConfig ?? null : null,
|
||||
dataTypes: scout.dataTypes,
|
||||
directory: scout.scoutType === 'local' ? (scout as LocalScoutConfig).directory : undefined,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backend templates picker */}
|
||||
{showTemplatePicker && (
|
||||
<InlineScoutCreationStepper
|
||||
catalog={catalogQuery.data ?? []}
|
||||
isLoadingCatalog={catalogQuery.isPending}
|
||||
onCancel={() => setShowTemplatePicker(false)}
|
||||
onCreated={() => {
|
||||
setShowTemplatePicker(false);
|
||||
void utils.scout.local.list.invalidate();
|
||||
void utils.scout.cloud.list.invalidate();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Chatbot Journey dialog */}
|
||||
{journeyScout && (
|
||||
<JourneyDialog
|
||||
agentType={journeyScout.type}
|
||||
agentName={journeyScout.name}
|
||||
currentConfig={journeyScout.currentConfig}
|
||||
dataTypes={journeyScout.dataTypes}
|
||||
directory={journeyScout.directory}
|
||||
onClose={() => setJourneyScout(null)}
|
||||
onSaved={(agentConfig) => {
|
||||
const local = localScouts.find(a => a.id === journeyScout.id);
|
||||
if (local) {
|
||||
updateLocalMutation.mutate({ id: journeyScout.id, agentConfig }, {
|
||||
onSuccess: () => {
|
||||
void utils.scout.local.list.invalidate();
|
||||
setJourneyScout(null);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
setJourneyScout(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FolderOpen, Mail, MessageSquare, Cloud, ArrowRight } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AgentCatalogItem } from '../../../../shared/api-types';
|
||||
import type { AgentCatalogItem } from '../../../shared/api-types';
|
||||
|
||||
export function TemplateSelectCard({
|
||||
item,
|
||||
@@ -11,6 +12,8 @@ export function TemplateSelectCard({
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const comingSoon = item.type === 'teams' || item.type === 'outlook';
|
||||
const TYPE_CONFIG = {
|
||||
local_directory: { label: 'Local files', icon: <FolderOpen className="w-3.5 h-3.5" />, className: 'bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-300' },
|
||||
gmail: { label: 'Gmail', icon: <Mail className="w-3.5 h-3.5" />, className: 'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300' },
|
||||
@@ -22,9 +25,10 @@ export function TemplateSelectCard({
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onSelect}
|
||||
tabIndex={comingSoon ? -1 : 0}
|
||||
onClick={comingSoon ? undefined : onSelect}
|
||||
onKeyDown={(e) => {
|
||||
if (comingSoon) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
@@ -37,6 +41,7 @@ export function TemplateSelectCard({
|
||||
selected
|
||||
? 'border-border bg-background'
|
||||
: 'border-border/60 bg-background hover:border-border',
|
||||
comingSoon && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
@@ -46,6 +51,7 @@ export function TemplateSelectCard({
|
||||
{badge.icon}
|
||||
{badge.label}
|
||||
</span>
|
||||
{comingSoon && <span className="text-[10px] uppercase tracking-wide text-muted-foreground">{t('scouts.comingSoon')}</span>}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
|
||||
@@ -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;
|
||||
|
||||
49
src/renderer/components/tasks/AssigneeStack.tsx
Normal file
49
src/renderer/components/tasks/AssigneeStack.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function initials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0]?.toUpperCase() ?? '')
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function AssigneeStack({
|
||||
assignees,
|
||||
className,
|
||||
}: {
|
||||
assignees: string[];
|
||||
className?: string;
|
||||
}) {
|
||||
if (assignees.length === 0) {
|
||||
return <span className="text-muted-foreground text-sm">—</span>;
|
||||
}
|
||||
const visible = assignees.slice(0, 2);
|
||||
const overflow = assignees.length - visible.length;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className={cn('flex items-center', className)}>
|
||||
{visible.map((name, i) => (
|
||||
<span
|
||||
key={name}
|
||||
className={cn(
|
||||
'flex h-6 w-6 items-center justify-center rounded-full bg-muted text-[10px] font-medium ring-2 ring-background',
|
||||
i > 0 && '-ml-2',
|
||||
)}
|
||||
>
|
||||
{initials(name)}
|
||||
</span>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span className="-ml-2 flex h-6 min-w-6 items-center justify-center rounded-full bg-muted px-1.5 text-[10px] font-medium ring-2 ring-background">
|
||||
+{overflow}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{assignees.join(', ')}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +1,19 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { TZDate } from 'react-day-picker';
|
||||
import { Calendar as CalendarIcon, X, UserPlus, Check } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useFormatPrefs, formatDate } from '@/lib/date';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TaskItem } from './TaskRow';
|
||||
import { TaskFormDialog, type TaskFormValues } from './TaskFormDialog';
|
||||
import type { TaskItem } from './task-types';
|
||||
import { parseAssignees } from './task-utils';
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
|
||||
const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
|
||||
|
||||
function parseAssigneesLocal(raw: string | null): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (Array.isArray(parsed)) return parsed.filter((n): n is string => typeof n === 'string');
|
||||
} catch { /* plain string fallback */ }
|
||||
return [raw];
|
||||
}
|
||||
|
||||
interface EditTaskDialogProps {
|
||||
interface Props {
|
||||
task: TaskItem | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function EditTaskDialog({ task, open, onOpenChange }: EditTaskDialogProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [priority, setPriority] = useState('medium');
|
||||
const [status, setStatus] = useState('todo');
|
||||
const prefs = useFormatPrefs();
|
||||
const timezone = prefs.timezone;
|
||||
const [dueDate, setDueDate] = useState<TZDate | undefined>();
|
||||
const [dueHour, setDueHour] = useState('');
|
||||
const [dueMinute, setDueMinute] = useState('');
|
||||
const [projectId, setProjectId] = useState('');
|
||||
const [assignees, setAssignees] = useState<string[]>([]);
|
||||
const [assigneeInput, setAssigneeInput] = useState('');
|
||||
const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false);
|
||||
|
||||
// Pre-fill fields whenever the task changes
|
||||
useEffect(() => {
|
||||
if (!task) return;
|
||||
setTitle(task.title);
|
||||
setDescription(task.description ?? '');
|
||||
setPriority(task.priority ?? 'medium');
|
||||
setStatus(task.status ?? 'todo');
|
||||
if (task.dueDate) {
|
||||
const d = new TZDate(task.dueDate, timezone);
|
||||
setDueDate(d);
|
||||
setDueHour(String(d.getHours()).padStart(2, '0'));
|
||||
setDueMinute(String(d.getMinutes()).padStart(2, '0'));
|
||||
} else {
|
||||
setDueDate(undefined);
|
||||
setDueHour('');
|
||||
setDueMinute('');
|
||||
}
|
||||
setProjectId(task.projectId ?? '');
|
||||
setAssignees(parseAssigneesLocal(task.assignee));
|
||||
setAssigneeInput('');
|
||||
setAssigneePopoverOpen(false);
|
||||
}, [task]);
|
||||
|
||||
const { data: projectsList } = trpc.projects.listAll.useQuery();
|
||||
const { data: knownAssignees = [] } = trpc.tasks.listAssignees.useQuery();
|
||||
export function EditTaskDialog({ task, open, onOpenChange }: Props) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { notify, notifyError } = useNotify();
|
||||
|
||||
const updateTask = trpc.tasks.update.useMutation({
|
||||
const update = trpc.tasks.update.useMutation({
|
||||
onSuccess: () => {
|
||||
notify('success', 'toast.task.updated');
|
||||
void utils.tasks.list.invalidate();
|
||||
@@ -100,291 +22,41 @@ export function EditTaskDialog({ task, open, onOpenChange }: EditTaskDialogProps
|
||||
onError: (err) => notifyError('toast.task.updateError', err),
|
||||
});
|
||||
|
||||
function addNewAssignee() {
|
||||
const name = assigneeInput.trim();
|
||||
if (!name || assignees.includes(name)) return;
|
||||
setAssignees((prev) => [...prev, name]);
|
||||
setAssigneeInput('');
|
||||
}
|
||||
if (!task) return null;
|
||||
const taskId = task.id;
|
||||
|
||||
function toggleAssignee(name: string) {
|
||||
setAssignees((prev) =>
|
||||
prev.includes(name) ? prev.filter((a) => a !== name) : [...prev, name],
|
||||
);
|
||||
}
|
||||
|
||||
function removeAssignee(name: string) {
|
||||
setAssignees((prev) => prev.filter((a) => a !== name));
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!task || !title.trim()) return;
|
||||
|
||||
let resolvedDueDate: number | undefined;
|
||||
if (dueDate) {
|
||||
const h = dueHour !== '' ? parseInt(dueHour, 10) : 0;
|
||||
const m = dueMinute !== '' ? parseInt(dueMinute, 10) : 0;
|
||||
const tzDate = new TZDate(
|
||||
dueDate.getFullYear(),
|
||||
dueDate.getMonth(),
|
||||
dueDate.getDate(),
|
||||
h, m, 0, 0,
|
||||
timezone,
|
||||
);
|
||||
resolvedDueDate = tzDate.getTime();
|
||||
}
|
||||
|
||||
updateTask.mutate({
|
||||
id: task.id,
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
priority,
|
||||
status,
|
||||
dueDate: resolvedDueDate,
|
||||
projectId: projectId || undefined,
|
||||
assignees: assignees.length ? assignees : undefined,
|
||||
function handleSubmit(values: TaskFormValues) {
|
||||
update.mutate({
|
||||
id: taskId,
|
||||
title: values.title,
|
||||
description: values.description || undefined,
|
||||
priority: values.priority,
|
||||
status: values.status,
|
||||
dueDate: values.dueDate ?? undefined,
|
||||
projectId: values.projectId ?? undefined,
|
||||
assignees: values.assignees,
|
||||
estimate: values.estimate,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[560px]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Task</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{/* Title */}
|
||||
<Input
|
||||
placeholder="Task title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<Textarea
|
||||
placeholder="Description (optional)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="min-h-20"
|
||||
/>
|
||||
|
||||
{/* Priority */}
|
||||
<Select value={priority} onValueChange={setPriority}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Priority" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Status */}
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">To Do</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="done">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Due Date + Time */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'justify-start text-left font-normal',
|
||||
!dueDate && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{dueDate
|
||||
? `${formatDate(dueDate.getTime(), prefs)}${dueHour !== '' && dueMinute !== '' ? ` ${dueHour}:${dueMinute}` : ''}`
|
||||
: 'Pick a due date'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={dueDate}
|
||||
onSelect={(d) => setDueDate(d as TZDate | undefined)}
|
||||
timeZone={timezone}
|
||||
/>
|
||||
<div className="border-t px-3 py-2 flex flex-col gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Time (optional, 24h)</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Select value={dueHour} onValueChange={setDueHour}>
|
||||
<SelectTrigger className="h-8 w-20 text-sm">
|
||||
<SelectValue placeholder="HH" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HOURS.map((h) => (
|
||||
<SelectItem key={h} value={h}>{h}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-muted-foreground text-sm">:</span>
|
||||
<Select value={dueMinute} onValueChange={setDueMinute}>
|
||||
<SelectTrigger className="h-8 w-20 text-sm">
|
||||
<SelectValue placeholder="MM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MINUTES.map((m) => (
|
||||
<SelectItem key={m} value={m}>{m}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(dueHour !== '' || dueMinute !== '') && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs"
|
||||
onClick={() => { setDueHour(''); setDueMinute(''); }}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{dueDate && dueHour !== '' && dueMinute !== '' && (
|
||||
<p className="text-xs text-muted-foreground pl-1">
|
||||
Due: {formatDate(dueDate.getTime(), prefs)} at {dueHour}:{dueMinute}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project */}
|
||||
<Select
|
||||
value={projectId || 'none'}
|
||||
onValueChange={(v) => setProjectId(v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Project (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No project</SelectItem>
|
||||
{projectsList?.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Assignees */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{assignees.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{assignees.map((name) => (
|
||||
<Badge key={name} variant="secondary" className="gap-1 pr-1">
|
||||
{name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAssignee(name)}
|
||||
className="ml-0.5 rounded-sm opacity-70 hover:opacity-100"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Popover open={assigneePopoverOpen} onOpenChange={setAssigneePopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'justify-start font-normal',
|
||||
assignees.length === 0 && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{assignees.length > 0
|
||||
? `${assignees.length} assignee${assignees.length > 1 ? 's' : ''}`
|
||||
: 'Add assignees'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-2" align="start">
|
||||
{knownAssignees.length > 0 && (
|
||||
<ScrollArea className="max-h-36 mb-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{knownAssignees.map((name) => (
|
||||
<Button
|
||||
key={name}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start h-8 px-2"
|
||||
onClick={() => toggleAssignee(name)}
|
||||
>
|
||||
{assignees.includes(name) ? (
|
||||
<Check className="h-3 w-3 mr-2 text-primary shrink-0" />
|
||||
) : (
|
||||
<span className="w-5 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{name}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
{knownAssignees.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground px-2 py-1 mb-2">No existing assignees</p>
|
||||
)}
|
||||
<Separator className="mb-2" />
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="New name…"
|
||||
value={assigneeInput}
|
||||
onChange={(e) => setAssigneeInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addNewAssignee();
|
||||
}
|
||||
}}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={addNewAssignee}
|
||||
disabled={!assigneeInput.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!title.trim() || updateTask.isPending}>
|
||||
{updateTask.isPending ? 'Saving…' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<TaskFormDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
mode="edit"
|
||||
taskId={taskId}
|
||||
initialValues={{
|
||||
title: task.title,
|
||||
description: task.description ?? '',
|
||||
priority: task.priority ?? 'medium',
|
||||
status: task.status ?? 'todo',
|
||||
dueDate: task.dueDate ?? null,
|
||||
projectId: task.projectId ?? null,
|
||||
assignees: parseAssignees(task.assignee),
|
||||
estimate: task.estimate ?? null,
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={update.isPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
257
src/renderer/components/tasks/InlineProjectForm.tsx
Normal file
257
src/renderer/components/tasks/InlineProjectForm.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
const NO_CLIENT = '__no_client__';
|
||||
|
||||
interface InlineProjectFormProps {
|
||||
onCancel: () => void;
|
||||
onCreated: (projectId: string) => void;
|
||||
}
|
||||
|
||||
export function InlineProjectForm({ onCancel, onCreated }: InlineProjectFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const { notify, notifyError } = useNotify();
|
||||
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
const [newProjectClientId, setNewProjectClientId] = useState(NO_CLIENT);
|
||||
const [newProjectSubClientId, setNewProjectSubClientId] = useState(NO_CLIENT);
|
||||
const [creatingClient, setCreatingClient] = useState(false);
|
||||
const [newClientName, setNewClientName] = useState('');
|
||||
const [creatingSubClient, setCreatingSubClient] = useState(false);
|
||||
const [newSubClientName, setNewSubClientName] = useState('');
|
||||
|
||||
const { data: clientList = [] } = trpc.clients.list.useQuery();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const topLevelClients = useMemo(() => clientList.filter((c) => !c.parentId), [clientList]);
|
||||
const subClientsByParent = useMemo(() => {
|
||||
const m = new Map<string, typeof clientList>();
|
||||
for (const c of clientList) {
|
||||
if (c.parentId) {
|
||||
const arr = m.get(c.parentId) ?? [];
|
||||
arr.push(c);
|
||||
m.set(c.parentId, arr);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}, [clientList]);
|
||||
|
||||
const createClientMutation = trpc.clients.create.useMutation({
|
||||
onSuccess: () => {
|
||||
notify('success', 'toast.client.created');
|
||||
void utils.clients.list.invalidate();
|
||||
},
|
||||
});
|
||||
const createProjectMutation = trpc.projects.create.useMutation({
|
||||
onSuccess: () => void utils.projects.listAll.invalidate(),
|
||||
});
|
||||
|
||||
const isSubmitting = createClientMutation.isPending || createProjectMutation.isPending;
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newProjectName.trim()) return;
|
||||
try {
|
||||
let resolvedClientId: string | undefined;
|
||||
|
||||
if (creatingClient && newClientName.trim()) {
|
||||
const r = await createClientMutation.mutateAsync({ name: newClientName.trim() });
|
||||
resolvedClientId = r.id;
|
||||
if (creatingSubClient && newSubClientName.trim()) {
|
||||
const sr = await createClientMutation.mutateAsync({
|
||||
name: newSubClientName.trim(),
|
||||
parentId: resolvedClientId,
|
||||
});
|
||||
resolvedClientId = sr.id;
|
||||
}
|
||||
} else if (newProjectClientId !== NO_CLIENT) {
|
||||
if (creatingSubClient && newSubClientName.trim()) {
|
||||
const sr = await createClientMutation.mutateAsync({
|
||||
name: newSubClientName.trim(),
|
||||
parentId: newProjectClientId,
|
||||
});
|
||||
resolvedClientId = sr.id;
|
||||
} else if (newProjectSubClientId !== NO_CLIENT) {
|
||||
resolvedClientId = newProjectSubClientId;
|
||||
} else {
|
||||
resolvedClientId = newProjectClientId;
|
||||
}
|
||||
}
|
||||
|
||||
const r = await createProjectMutation.mutateAsync({
|
||||
name: newProjectName.trim(),
|
||||
clientId: resolvedClientId,
|
||||
});
|
||||
onCreated(r.id);
|
||||
} catch (err) {
|
||||
notifyError('toast.project.createError', err as Error);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{t('projects.newProject')}</span>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Project name */}
|
||||
<Input
|
||||
placeholder={t('projects.projectNamePlaceholder')}
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
autoFocus
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
|
||||
{/* Client selection */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t('projects.clientOptional')} <span className="opacity-60">{t('projects.clientOptionalHint')}</span>
|
||||
</label>
|
||||
{creatingClient ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
placeholder={t('projects.newClientName')}
|
||||
value={newClientName}
|
||||
onChange={(e) => setNewClientName(e.target.value)}
|
||||
className="flex-1 h-8 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCreatingClient(false);
|
||||
setNewClientName('');
|
||||
setCreatingSubClient(false);
|
||||
setNewSubClientName('');
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Select
|
||||
value={newProjectClientId}
|
||||
onValueChange={(v) => {
|
||||
setNewProjectClientId(v);
|
||||
setNewProjectSubClientId(NO_CLIENT);
|
||||
setCreatingSubClient(false);
|
||||
setNewSubClientName('');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="flex-1 h-8 text-sm">
|
||||
<SelectValue placeholder={t('projects.selectClient')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_CLIENT}>{t('projects.noneInternal')}</SelectItem>
|
||||
{topLevelClients.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCreatingClient(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />{t('projects.new')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sub-client selection */}
|
||||
{(newProjectClientId !== NO_CLIENT || (creatingClient && newClientName.trim())) && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t('projects.subClientOptional')} <span className="opacity-60">{t('projects.clientOptionalHint')}</span>
|
||||
</label>
|
||||
{creatingSubClient ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
placeholder={t('projects.newSubClientName')}
|
||||
value={newSubClientName}
|
||||
onChange={(e) => setNewSubClientName(e.target.value)}
|
||||
className="flex-1 h-8 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCreatingSubClient(false);
|
||||
setNewSubClientName('');
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
) : creatingClient ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => setCreatingSubClient(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />{t('projects.newSubClient')}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Select
|
||||
value={newProjectSubClientId}
|
||||
onValueChange={setNewProjectSubClientId}
|
||||
>
|
||||
<SelectTrigger className="flex-1 h-8 text-sm">
|
||||
<SelectValue placeholder={t('projects.selectSubClient')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_CLIENT}>{t('projects.none')}</SelectItem>
|
||||
{(subClientsByParent.get(newProjectClientId) ?? []).map((sc) => (
|
||||
<SelectItem key={sc.id} value={sc.id}>{sc.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCreatingSubClient(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />{t('projects.new')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={!newProjectName.trim() || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t('common.creating') : t('projects.createProject')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,633 +1,50 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TZDate } from 'react-day-picker';
|
||||
import { Calendar as CalendarIcon, X, UserPlus, Check, Plus } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { useFormatPrefs, formatDate } from '@/lib/date';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TaskFormDialog, type TaskFormValues } from './TaskFormDialog';
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
|
||||
const MINUTES = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'];
|
||||
|
||||
const NO_CLIENT = '__no_client__';
|
||||
|
||||
interface NewTaskDialogProps {
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
defaultProjectId?: string;
|
||||
defaultStatus?: string;
|
||||
}
|
||||
|
||||
export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultStatus }: NewTaskDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [priority, setPriority] = useState('medium');
|
||||
const [status, setStatus] = useState(defaultStatus ?? 'todo');
|
||||
const [dueDate, setDueDate] = useState<TZDate | undefined>();
|
||||
const [dueHour, setDueHour] = useState('');
|
||||
const [dueMinute, setDueMinute] = useState('');
|
||||
const prefs = useFormatPrefs();
|
||||
const timezone = prefs.timezone;
|
||||
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
|
||||
|
||||
// Multi-assignee state
|
||||
const [assignees, setAssignees] = useState<string[]>([]);
|
||||
const [assigneeInput, setAssigneeInput] = useState('');
|
||||
const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false);
|
||||
|
||||
// Inline project creation state
|
||||
const [creatingProject, setCreatingProject] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
const [newProjectClientId, setNewProjectClientId] = useState(NO_CLIENT);
|
||||
const [newProjectSubClientId, setNewProjectSubClientId] = useState(NO_CLIENT);
|
||||
const [creatingClient, setCreatingClient] = useState(false);
|
||||
const [newClientName, setNewClientName] = useState('');
|
||||
const [creatingSubClient, setCreatingSubClient] = useState(false);
|
||||
const [newSubClientName, setNewSubClientName] = useState('');
|
||||
|
||||
const { data: projectsList } = trpc.projects.listAll.useQuery();
|
||||
const { data: clientList = [] } = trpc.clients.list.useQuery();
|
||||
const { data: knownAssignees = [] } = trpc.tasks.listAssignees.useQuery();
|
||||
export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultStatus }: Props) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const topLevelClients = useMemo(() => clientList.filter((c) => !c.parentId), [clientList]);
|
||||
const subClientsByParent = useMemo(() => {
|
||||
const m = new Map<string, typeof clientList>();
|
||||
for (const c of clientList) {
|
||||
if (c.parentId) {
|
||||
const arr = m.get(c.parentId) ?? [];
|
||||
arr.push(c);
|
||||
m.set(c.parentId, arr);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}, [clientList]);
|
||||
|
||||
const { notify, notifyError } = useNotify();
|
||||
|
||||
const createClientMutation = trpc.clients.create.useMutation({
|
||||
onSuccess: () => {
|
||||
notify('success', 'toast.client.created');
|
||||
void utils.clients.list.invalidate();
|
||||
},
|
||||
});
|
||||
const createProjectMutation = trpc.projects.create.useMutation({
|
||||
onSuccess: () => void utils.projects.listAll.invalidate(),
|
||||
});
|
||||
const createTask = trpc.tasks.create.useMutation({
|
||||
const create = trpc.tasks.create.useMutation({
|
||||
onSuccess: () => {
|
||||
notify('success', 'toast.task.created');
|
||||
void utils.tasks.list.invalidate();
|
||||
resetAndClose();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => notifyError('toast.task.createError', err),
|
||||
});
|
||||
|
||||
function resetAndClose() {
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setPriority('medium');
|
||||
setStatus(defaultStatus ?? 'todo');
|
||||
setDueDate(undefined);
|
||||
setDueHour('');
|
||||
setDueMinute('');
|
||||
setProjectId(defaultProjectId ?? '');
|
||||
setAssignees([]);
|
||||
setAssigneeInput('');
|
||||
setAssigneePopoverOpen(false);
|
||||
resetProjectCreation();
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function resetProjectCreation() {
|
||||
setCreatingProject(false);
|
||||
setNewProjectName('');
|
||||
setNewProjectClientId(NO_CLIENT);
|
||||
setNewProjectSubClientId(NO_CLIENT);
|
||||
setCreatingClient(false);
|
||||
setNewClientName('');
|
||||
setCreatingSubClient(false);
|
||||
setNewSubClientName('');
|
||||
}
|
||||
|
||||
function addNewAssignee() {
|
||||
const name = assigneeInput.trim();
|
||||
if (!name || assignees.includes(name)) return;
|
||||
setAssignees((prev) => [...prev, name]);
|
||||
setAssigneeInput('');
|
||||
}
|
||||
|
||||
function toggleAssignee(name: string) {
|
||||
setAssignees((prev) =>
|
||||
prev.includes(name) ? prev.filter((a) => a !== name) : [...prev, name],
|
||||
);
|
||||
}
|
||||
|
||||
function removeAssignee(name: string) {
|
||||
setAssignees((prev) => prev.filter((a) => a !== name));
|
||||
}
|
||||
|
||||
async function handleCreateInlineProject(): Promise<string | undefined> {
|
||||
let resolvedClientId: string | undefined;
|
||||
|
||||
if (creatingClient && newClientName.trim()) {
|
||||
const r = await createClientMutation.mutateAsync({ name: newClientName.trim() });
|
||||
resolvedClientId = r.id;
|
||||
if (creatingSubClient && newSubClientName.trim()) {
|
||||
const sr = await createClientMutation.mutateAsync({
|
||||
name: newSubClientName.trim(),
|
||||
parentId: resolvedClientId,
|
||||
});
|
||||
resolvedClientId = sr.id;
|
||||
}
|
||||
} else if (newProjectClientId !== NO_CLIENT) {
|
||||
if (creatingSubClient && newSubClientName.trim()) {
|
||||
const sr = await createClientMutation.mutateAsync({
|
||||
name: newSubClientName.trim(),
|
||||
parentId: newProjectClientId,
|
||||
});
|
||||
resolvedClientId = sr.id;
|
||||
} else if (newProjectSubClientId !== NO_CLIENT) {
|
||||
resolvedClientId = newProjectSubClientId;
|
||||
} else {
|
||||
resolvedClientId = newProjectClientId;
|
||||
}
|
||||
}
|
||||
|
||||
const r = await createProjectMutation.mutateAsync({
|
||||
name: newProjectName.trim(),
|
||||
clientId: resolvedClientId,
|
||||
});
|
||||
return r.id;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
|
||||
// Resolve dueDate + optional time in the selected timezone
|
||||
let resolvedDueDate: number | undefined;
|
||||
if (dueDate) {
|
||||
const h = dueHour !== '' ? parseInt(dueHour, 10) : 0;
|
||||
const m = dueMinute !== '' ? parseInt(dueMinute, 10) : 0;
|
||||
const tzDate = new TZDate(
|
||||
dueDate.getFullYear(),
|
||||
dueDate.getMonth(),
|
||||
dueDate.getDate(),
|
||||
h, m, 0, 0,
|
||||
timezone,
|
||||
);
|
||||
resolvedDueDate = tzDate.getTime();
|
||||
}
|
||||
|
||||
// If creating a new project inline, do that first
|
||||
let resolvedProjectId = projectId || undefined;
|
||||
if (creatingProject && newProjectName.trim()) {
|
||||
resolvedProjectId = await handleCreateInlineProject();
|
||||
}
|
||||
|
||||
createTask.mutate({
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
priority,
|
||||
status,
|
||||
dueDate: resolvedDueDate,
|
||||
projectId: resolvedProjectId,
|
||||
assignees: assignees.length ? assignees : undefined,
|
||||
function handleSubmit(values: TaskFormValues) {
|
||||
create.mutate({
|
||||
title: values.title,
|
||||
description: values.description || undefined,
|
||||
priority: values.priority,
|
||||
status: values.status,
|
||||
dueDate: values.dueDate ?? undefined,
|
||||
projectId: values.projectId ?? undefined,
|
||||
assignees: values.assignees.length ? values.assignees : undefined,
|
||||
estimate: values.estimate ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const isSubmitting =
|
||||
createTask.isPending ||
|
||||
createClientMutation.isPending ||
|
||||
createProjectMutation.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[560px]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('tasks.newTask')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{/* Title */}
|
||||
<Input
|
||||
placeholder={t('tasks.taskTitle')}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<Textarea
|
||||
placeholder={t('tasks.descriptionOptional')}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="min-h-20"
|
||||
/>
|
||||
|
||||
{/* Priority */}
|
||||
<Select value={priority} onValueChange={setPriority}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Priority" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="high">{t('tasks.high')}</SelectItem>
|
||||
<SelectItem value="medium">{t('tasks.medium')}</SelectItem>
|
||||
<SelectItem value="low">{t('tasks.low')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Status */}
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">{t('tasks.toDo')}</SelectItem>
|
||||
<SelectItem value="in_progress">{t('tasks.inProgress')}</SelectItem>
|
||||
<SelectItem value="done">{t('tasks.completed')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Due Date + Time */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'justify-start text-left font-normal',
|
||||
!dueDate && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{dueDate
|
||||
? `${formatDate(dueDate.getTime(), prefs)}${dueHour !== '' && dueMinute !== '' ? ` ${dueHour}:${dueMinute}` : ''}`
|
||||
: t('tasks.pickDueDate')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={dueDate}
|
||||
onSelect={(d) => setDueDate(d as TZDate | undefined)}
|
||||
timeZone={timezone}
|
||||
/>
|
||||
<div className="border-t px-3 py-2 flex flex-col gap-2">
|
||||
{/* Time row */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Time (optional, 24h)</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Select value={dueHour} onValueChange={setDueHour}>
|
||||
<SelectTrigger className="h-8 w-20 text-sm">
|
||||
<SelectValue placeholder="HH" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HOURS.map((h) => (
|
||||
<SelectItem key={h} value={h}>{h}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-muted-foreground text-sm">:</span>
|
||||
<Select value={dueMinute} onValueChange={setDueMinute}>
|
||||
<SelectTrigger className="h-8 w-20 text-sm">
|
||||
<SelectValue placeholder="MM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MINUTES.map((m) => (
|
||||
<SelectItem key={m} value={m}>{m}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(dueHour !== '' || dueMinute !== '') && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs"
|
||||
onClick={() => { setDueHour(''); setDueMinute(''); }}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{dueDate && dueHour !== '' && dueMinute !== '' && (
|
||||
<p className="text-xs text-muted-foreground pl-1">
|
||||
Due: {formatDate(dueDate.getTime(), prefs)} at {dueHour}:{dueMinute}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project */}
|
||||
{!creatingProject ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={projectId || 'none'}
|
||||
onValueChange={(v) => setProjectId(v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Project (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No project</SelectItem>
|
||||
{projectsList?.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCreatingProject(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />New
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">New Project</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={resetProjectCreation}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Project name */}
|
||||
<Input
|
||||
placeholder="Project name"
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Client selection */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Client <span className="opacity-60">(optional)</span>
|
||||
</label>
|
||||
{creatingClient ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="New client name"
|
||||
value={newClientName}
|
||||
onChange={(e) => setNewClientName(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCreatingClient(false);
|
||||
setNewClientName('');
|
||||
setCreatingSubClient(false);
|
||||
setNewSubClientName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={newProjectClientId}
|
||||
onValueChange={(v) => {
|
||||
setNewProjectClientId(v);
|
||||
setNewProjectSubClientId(NO_CLIENT);
|
||||
setCreatingSubClient(false);
|
||||
setNewSubClientName('');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Select a client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_CLIENT}>None (Internal)</SelectItem>
|
||||
{topLevelClients.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCreatingClient(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />New
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sub-client selection — only when a client is selected or being created */}
|
||||
{(newProjectClientId !== NO_CLIENT || (creatingClient && newClientName.trim())) && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Sub-client <span className="opacity-60">(optional)</span>
|
||||
</label>
|
||||
{creatingSubClient ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="New sub-client name"
|
||||
value={newSubClientName}
|
||||
onChange={(e) => setNewSubClientName(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCreatingSubClient(false);
|
||||
setNewSubClientName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : creatingClient ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => setCreatingSubClient(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />New Sub-client
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={newProjectSubClientId}
|
||||
onValueChange={setNewProjectSubClientId}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Select a sub-client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_CLIENT}>None</SelectItem>
|
||||
{(subClientsByParent.get(newProjectClientId) ?? []).map((sc) => (
|
||||
<SelectItem key={sc.id} value={sc.id}>{sc.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCreatingSubClient(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />New
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignees */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Selected assignee badges */}
|
||||
{assignees.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{assignees.map((name) => (
|
||||
<Badge key={name} variant="secondary" className="gap-1 pr-1">
|
||||
{name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAssignee(name)}
|
||||
className="ml-0.5 rounded-sm opacity-70 hover:opacity-100"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignee picker popover */}
|
||||
<Popover open={assigneePopoverOpen} onOpenChange={setAssigneePopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'justify-start font-normal',
|
||||
assignees.length === 0 && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{assignees.length > 0
|
||||
? `${assignees.length} assignee${assignees.length > 1 ? 's' : ''}`
|
||||
: 'Add assignees'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-2" align="start">
|
||||
{/* Known assignees list */}
|
||||
{knownAssignees.length > 0 && (
|
||||
<ScrollArea className="max-h-36 mb-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{knownAssignees.map((name) => (
|
||||
<Button
|
||||
key={name}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start h-8 px-2"
|
||||
onClick={() => toggleAssignee(name)}
|
||||
>
|
||||
{assignees.includes(name) ? (
|
||||
<Check className="h-3 w-3 mr-2 text-primary shrink-0" />
|
||||
) : (
|
||||
<span className="w-5 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{name}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
{knownAssignees.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground px-2 py-1 mb-2">No existing assignees</p>
|
||||
)}
|
||||
<Separator className="mb-2" />
|
||||
{/* Add new assignee */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="New name…"
|
||||
value={assigneeInput}
|
||||
onChange={(e) => setAssigneeInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addNewAssignee();
|
||||
}
|
||||
}}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={addNewAssignee}
|
||||
disabled={!assigneeInput.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={resetAndClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!title.trim() || isSubmitting}>
|
||||
{isSubmitting ? 'Creating…' : 'Create Task'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<TaskFormDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
mode="create"
|
||||
initialValues={{
|
||||
projectId: defaultProjectId ?? null,
|
||||
status: defaultStatus ?? 'todo',
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={create.isPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
40
src/renderer/components/tasks/PropertyPill.tsx
Normal file
40
src/renderer/components/tasks/PropertyPill.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface PropertyPillProps
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value?: string | null;
|
||||
empty?: boolean;
|
||||
}
|
||||
|
||||
export const PropertyPill = forwardRef<HTMLButtonElement, PropertyPillProps>(
|
||||
({ icon, label, value, empty, className, ...rest }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
data-empty={empty ? 'true' : undefined}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:border-ring',
|
||||
empty
|
||||
? 'border border-dashed border-border text-muted-foreground hover:text-foreground hover:border-foreground/50'
|
||||
: 'border border-border/60 bg-background/60 text-foreground hover:border-ring/40',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<span className="flex items-center">{icon}</span>
|
||||
{empty ? (
|
||||
<span>{label}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground">{label}:</span>
|
||||
<span>{value}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
),
|
||||
);
|
||||
PropertyPill.displayName = 'PropertyPill';
|
||||
39
src/renderer/components/tasks/StatusBadge.tsx
Normal file
39
src/renderer/components/tasks/StatusBadge.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Circle, Clock, CheckCircle2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
todo: {
|
||||
icon: Circle,
|
||||
className: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
||||
labelKey: 'tasks.toDo',
|
||||
},
|
||||
in_progress: {
|
||||
icon: Clock,
|
||||
className: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
labelKey: 'tasks.inProgress',
|
||||
},
|
||||
done: {
|
||||
icon: CheckCircle2,
|
||||
className: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
labelKey: 'tasks.done',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function StatusBadge({ status, className }: { status: string | null; className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const conf = STATUS_CONFIG[(status ?? 'todo') as keyof typeof STATUS_CONFIG] ?? STATUS_CONFIG.todo;
|
||||
const Icon = conf.icon;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
conf.className,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{t(conf.labelKey)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
37
src/renderer/components/tasks/TaskAttachmentChip.tsx
Normal file
37
src/renderer/components/tasks/TaskAttachmentChip.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Paperclip, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function TaskAttachmentChip({
|
||||
filename,
|
||||
sizeBytes,
|
||||
onOpen,
|
||||
onDelete,
|
||||
}: {
|
||||
filename: string;
|
||||
sizeBytes: number;
|
||||
onOpen: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-background/60 px-2.5 py-1 text-xs',
|
||||
)}
|
||||
>
|
||||
<button type="button" onClick={onOpen} className="flex items-center gap-1.5 hover:underline">
|
||||
<Paperclip className="h-3 w-3" />
|
||||
<span className="max-w-[180px] truncate">{filename}</span>
|
||||
<span className="text-muted-foreground">· {formatSize(sizeBytes)}</span>
|
||||
</button>
|
||||
<button type="button" onClick={onDelete} className="text-muted-foreground hover:text-destructive">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Fragment } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Calendar, User, Pencil, Trash2, Sparkles } from 'lucide-react';
|
||||
import { Calendar, User, Pencil, Trash2, Sparkles, RefreshCw, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
@@ -17,37 +16,21 @@ import {
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuSubContent,
|
||||
} from '@/components/ui/context-menu';
|
||||
import { PriorityBadge } from './PriorityBadge';
|
||||
import { type TaskItem } from './TaskRow';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { type TaskItem } from './task-types';
|
||||
import { useFormatPrefs, formatDueDate } from '@/lib/date';
|
||||
import { parseAssignees } from './task-utils';
|
||||
|
||||
function StatusBadge({ status }: { status: string | null }) {
|
||||
const { t } = useTranslation();
|
||||
if (!status) return null;
|
||||
const label =
|
||||
status === 'todo' ? t('tasks.toDo') :
|
||||
status === 'in_progress' ? t('tasks.inProgress') :
|
||||
status === 'done' ? t('tasks.done') : null;
|
||||
if (!label) return null;
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
status === 'in_progress' && 'border-sky-300 dark:border-sky-800 bg-sky-50 dark:bg-sky-950/30 text-sky-700 dark:text-sky-400',
|
||||
status === 'done' && 'border-green-300 dark:border-green-800 bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
const STATUSES = ['todo', 'in_progress', 'done'] as const;
|
||||
|
||||
export function TaskCard({
|
||||
task,
|
||||
onToggle,
|
||||
onStatusChange,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onClick,
|
||||
@@ -55,7 +38,7 @@ export function TaskCard({
|
||||
layoutId,
|
||||
}: {
|
||||
task: TaskItem;
|
||||
onToggle: (id: string, status: string | null) => void;
|
||||
onStatusChange: (id: string, status: string) => void;
|
||||
onEdit?: (task: TaskItem) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
onClick?: (task: TaskItem) => void;
|
||||
@@ -66,10 +49,6 @@ export function TaskCard({
|
||||
const prefs = useFormatPrefs();
|
||||
const isDone = task.status === 'done';
|
||||
|
||||
const checkboxState: boolean | 'indeterminate' =
|
||||
task.status === 'done' ? true :
|
||||
task.status === 'in_progress' ? 'indeterminate' : false;
|
||||
|
||||
const breadcrumb: string[] = [];
|
||||
if (!hideBreadcrumb) {
|
||||
if (task.clientName) breadcrumb.push(task.clientName);
|
||||
@@ -97,16 +76,10 @@ export function TaskCard({
|
||||
)}
|
||||
onClick={() => onClick?.(task)}
|
||||
>
|
||||
{/* Header: checkbox + title */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={checkboxState}
|
||||
onCheckedChange={() => onToggle(task.id, task.status)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
{/* Header: title (no checkbox) */}
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn('flex items-center gap-1 text-sm font-medium min-w-0', isDone && 'line-through text-muted-foreground')}>
|
||||
<div className={cn('flex items-center gap-1.5 text-sm font-medium min-w-0', isDone && 'line-through text-muted-foreground')}>
|
||||
{task.isAiSuggested ? <Sparkles className="h-3 w-3 shrink-0 text-amber-500" /> : null}
|
||||
<span className="truncate">{task.title}</span>
|
||||
</div>
|
||||
@@ -168,6 +141,20 @@ export function TaskCard({
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
{t('common.edit')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
{t('tasks.changeStatus')}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
{STATUSES.map((s) => (
|
||||
<ContextMenuItem key={s} onSelect={() => onStatusChange(task.id, s)}>
|
||||
{task.status === s ? <Check className="h-3 w-3 mr-2" /> : <span className="w-5" />}
|
||||
{t(s === 'todo' ? 'tasks.toDo' : s === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuItem
|
||||
onSelect={() => onDelete?.(task.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Calendar,
|
||||
User,
|
||||
CircleDot,
|
||||
FolderOpen,
|
||||
Zap,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { PriorityBadge } from './PriorityBadge';
|
||||
import { parseAssignees, type TaskItem } from './TaskRow';
|
||||
import { useFormatPrefs, formatDueDate, formatRelative } from '@/lib/date';
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; className: string }> = {
|
||||
todo: { label: 'To Do', className: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' },
|
||||
in_progress: { label: 'In Progress', className: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' },
|
||||
done: { label: 'Done', className: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' },
|
||||
};
|
||||
|
||||
function AuthorAvatar({ name }: { name: string }) {
|
||||
const initials = name
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0]?.toUpperCase() ?? '')
|
||||
.join('');
|
||||
return (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium">
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TaskDetailDialogProps {
|
||||
task: TaskItem | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onEdit: (task: TaskItem) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function TaskDetailDialog({ task, open, onOpenChange, onEdit, onDelete }: TaskDetailDialogProps) {
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('description');
|
||||
const prefs = useFormatPrefs();
|
||||
|
||||
const { data: comments } = trpc.taskComments.list.useQuery(
|
||||
{ taskId: task?.id ?? '' },
|
||||
{ enabled: !!task },
|
||||
);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { notify, notifyError } = useNotify();
|
||||
|
||||
const addComment = trpc.taskComments.create.useMutation({
|
||||
onSuccess: () => {
|
||||
notify('success', 'toast.comment.created');
|
||||
void utils.taskComments.list.invalidate({ taskId: task?.id ?? '' });
|
||||
setCommentText('');
|
||||
},
|
||||
onError: (err) => notifyError('toast.comment.createError', err),
|
||||
});
|
||||
|
||||
const deleteComment = trpc.taskComments.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
notify('warning', 'toast.comment.deleted');
|
||||
void utils.taskComments.list.invalidate({ taskId: task?.id ?? '' });
|
||||
},
|
||||
onError: (err) => notifyError('toast.comment.deleteError', err),
|
||||
});
|
||||
|
||||
if (!task) return null;
|
||||
|
||||
const assignees = parseAssignees(task.assignee);
|
||||
const statusConf = STATUS_CONFIG[task.status ?? 'todo'] ?? { label: 'To Do', className: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' };
|
||||
const breadcrumb = [task.clientName, task.subClientName, task.projectName].filter(Boolean);
|
||||
|
||||
const handleAddComment = () => {
|
||||
const text = commentText.trim();
|
||||
if (!text) return;
|
||||
addComment.mutate({ taskId: task.id, author: 'Me', content: text });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[620px] gap-0 p-0" aria-describedby={undefined}>
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-6 pt-6 pb-4">
|
||||
<DialogTitle className="text-lg font-semibold leading-tight">{task.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Field rows */}
|
||||
<div className="grid grid-cols-[120px_1fr] gap-y-3 px-6 py-4 text-sm">
|
||||
{/* Assignee */}
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<User className="h-4 w-4" />
|
||||
Assignee
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{assignees.length > 0 ? (
|
||||
assignees.map((name) => (
|
||||
<Badge key={name} variant="secondary" className="text-xs">
|
||||
{name}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground">Unassigned</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<CircleDot className="h-4 w-4" />
|
||||
Status
|
||||
</div>
|
||||
<div>
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${statusConf.className}`}>
|
||||
{statusConf.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Due date */}
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Due date
|
||||
</div>
|
||||
<div>
|
||||
{task.dueDate ? formatDueDate(task.dueDate, prefs) : <span className="text-muted-foreground">No due date</span>}
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Zap className="h-4 w-4" />
|
||||
Priority
|
||||
</div>
|
||||
<div>
|
||||
<PriorityBadge priority={task.priority} />
|
||||
</div>
|
||||
|
||||
{/* Project */}
|
||||
{breadcrumb.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Project
|
||||
</div>
|
||||
<div className="text-sm">{breadcrumb.join(' > ')}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Tabs: Description / Comment */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col">
|
||||
<TabsList className="mx-6 mt-3 w-fit">
|
||||
<TabsTrigger value="description">Description</TabsTrigger>
|
||||
<TabsTrigger value="comment">Comment</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="description" className="px-6 py-4 min-h-[120px]">
|
||||
{task.description ? (
|
||||
<p className="text-sm whitespace-pre-wrap">{task.description}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No description provided.</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="comment" className="px-6 py-4 min-h-[120px] flex flex-col gap-4">
|
||||
{/* Comment list */}
|
||||
<ScrollArea className="max-h-[260px]">
|
||||
<div className="flex flex-col gap-4">
|
||||
{(!comments || comments.length === 0) ? (
|
||||
<p className="text-sm text-muted-foreground italic">No comments yet.</p>
|
||||
) : (
|
||||
comments.map((c) => (
|
||||
<div key={c.id} className="flex gap-3">
|
||||
<AuthorAvatar name={c.author} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium">{c.author}</span>
|
||||
<span className="text-xs text-muted-foreground">{formatRelative(c.createdAt)}</span>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted px-3 py-2 text-sm">
|
||||
{c.content}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:text-destructive"
|
||||
onClick={() => deleteComment.mutate({ id: c.id })}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Add comment input */}
|
||||
<form
|
||||
className="flex items-center gap-2 mt-auto"
|
||||
onSubmit={(e) => { e.preventDefault(); handleAddComment(); }}
|
||||
>
|
||||
<AuthorAvatar name="Me" />
|
||||
<Input
|
||||
placeholder="Add a comment..."
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={!commentText.trim() || addComment.isPending}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Footer */}
|
||||
<DialogFooter className="px-6 py-4">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => { onDelete(task.id); onOpenChange(false); }}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => { onEdit(task); onOpenChange(false); }}
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
278
src/renderer/components/tasks/TaskDetailSheet.tsx
Normal file
278
src/renderer/components/tasks/TaskDetailSheet.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { MoreHorizontal, Pencil, Trash2, ChevronRight, Plus, X } from 'lucide-react';
|
||||
import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { PriorityBadge } from './PriorityBadge';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { type TaskItem } from './task-types';
|
||||
import { useFormatPrefs, formatDueDate, formatRelative } from '@/lib/date';
|
||||
import { parseAssignees } from './task-utils';
|
||||
import { AssigneeStack } from './AssigneeStack';
|
||||
import { TaskAttachmentChip } from './TaskAttachmentChip';
|
||||
import { useTaskAttachments } from './useTaskAttachments';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { ChatInputBox } from '@/components/ai/ChatInputBox';
|
||||
|
||||
interface Props {
|
||||
task: TaskItem | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onEdit: (task: TaskItem) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function TaskDetailSheet({ task, open, onOpenChange, onEdit, onDelete }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const prefs = useFormatPrefs();
|
||||
const navigate = useNavigate();
|
||||
const attachments = useTaskAttachments(task?.id ?? null);
|
||||
const utils = trpc.useUtils();
|
||||
const { notifyError } = useNotify();
|
||||
const { data: comments } = trpc.taskComments.list.useQuery(
|
||||
{ taskId: task?.id ?? '' },
|
||||
{ enabled: !!task },
|
||||
);
|
||||
const { data: fresh } = trpc.tasks.byIds.useQuery(
|
||||
{ ids: task ? [task.id] : [] },
|
||||
{ enabled: !!task },
|
||||
);
|
||||
const addComment = trpc.taskComments.create.useMutation({
|
||||
onSuccess: () => {
|
||||
if (task) void utils.taskComments.list.invalidate({ taskId: task.id });
|
||||
},
|
||||
onError: (err) => notifyError('toast.comment.createError', err),
|
||||
});
|
||||
const deleteComment = trpc.taskComments.delete.useMutation({
|
||||
onSuccess: () => task && void utils.taskComments.list.invalidate({ taskId: task.id }),
|
||||
});
|
||||
const updateTask = trpc.tasks.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.tasks.list.invalidate();
|
||||
void utils.tasks.byIds.invalidate();
|
||||
},
|
||||
onError: (err) => notifyError('toast.task.updateError', err),
|
||||
});
|
||||
const [priorityOpen, setPriorityOpen] = useState(false);
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
if (!task) return null;
|
||||
const liveTask = (fresh?.[0] as TaskItem | undefined) ?? task;
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
className="w-[480px] !max-w-[480px] flex flex-col p-0 gap-0 bg-card/85 backdrop-blur-xl border-border/50"
|
||||
>
|
||||
{/* Sticky header */}
|
||||
<div className="px-6 pt-6 pb-4 border-b border-border/40 shrink-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (liveTask.projectId) {
|
||||
onOpenChange(false);
|
||||
navigate({ to: '/projects', search: { projectId: liveTask.projectId } });
|
||||
}
|
||||
}}
|
||||
disabled={!liveTask.projectId}
|
||||
className="text-xs text-muted-foreground flex items-center gap-1 min-w-0 hover:text-foreground transition-colors disabled:cursor-default disabled:hover:text-muted-foreground"
|
||||
>
|
||||
{liveTask.clientName && <span className="truncate">{liveTask.clientName}</span>}
|
||||
{liveTask.clientName && liveTask.projectName && <ChevronRight className="h-3 w-3 shrink-0" />}
|
||||
{liveTask.projectName && <span className="text-foreground font-medium truncate">{liveTask.projectName}</span>}
|
||||
</button>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => onEdit(liveTask)}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
{t('common.edit')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onDelete(liveTask.id)} className="text-destructive focus:text-destructive">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t('common.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<SheetClose asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</SheetClose>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-lg font-semibold leading-tight mt-1">{liveTask.title}</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Popover open={priorityOpen} onOpenChange={setPriorityOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" className="rounded hover:bg-accent/50 px-1 -mx-1">
|
||||
<PriorityBadge priority={liveTask.priority} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-40 p-1" align="start">
|
||||
{(['high', 'medium', 'low'] as const).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
updateTask.mutate({ id: liveTask.id, priority: p });
|
||||
setPriorityOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50"
|
||||
>
|
||||
{t(`tasks.${p}`)}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" className="rounded hover:bg-accent/50 px-1 -mx-1">
|
||||
<StatusBadge status={liveTask.status} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-40 p-1" align="start">
|
||||
{(['todo', 'in_progress', 'done'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
updateTask.mutate({ id: liveTask.id, status: s });
|
||||
setStatusOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50"
|
||||
>
|
||||
{t(s === 'todo' ? 'tasks.toDo' : s === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrolling body */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-6 mt-4 rounded-lg border border-border/40 bg-background/40 p-4">
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-4">
|
||||
<PropRow label={t('tasks.assignee')}>
|
||||
<AssigneeStack assignees={parseAssignees(liveTask.assignee)} />
|
||||
</PropRow>
|
||||
<PropRow label={t('tasks.colDue')}>
|
||||
{liveTask.dueDate ? formatDueDate(liveTask.dueDate, prefs) : <span className="text-muted-foreground">—</span>}
|
||||
</PropRow>
|
||||
<PropRow label={t('tasks.estimate')}>
|
||||
<span className="text-muted-foreground">—</span>
|
||||
</PropRow>
|
||||
<PropRow label={t('tasks.created')}>
|
||||
{liveTask.createdAt ? formatRelative(liveTask.createdAt) : <span className="text-muted-foreground">—</span>}
|
||||
</PropRow>
|
||||
<div className="col-span-2">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2">
|
||||
{t('tasks.attachments')}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{(attachments.list.data ?? []).map((a) => (
|
||||
<TaskAttachmentChip
|
||||
key={a.id}
|
||||
filename={a.filename}
|
||||
sizeBytes={a.sizeBytes}
|
||||
onOpen={() => attachments.open.mutate({ id: a.id })}
|
||||
onDelete={() => attachments.remove.mutate({ id: a.id })}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => attachments.addFiles()}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-dashed border-border px-2.5 py-1 text-xs text-muted-foreground hover:text-foreground hover:border-foreground/50"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{t('tasks.addFile')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2">
|
||||
{t('tasks.description')}
|
||||
</div>
|
||||
{liveTask.description ? (
|
||||
<div className="text-sm whitespace-pre-wrap">{liveTask.description}</div>
|
||||
) : (
|
||||
<div className="text-sm italic text-muted-foreground">{t('tasks.noDescription')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/40 mx-6" />
|
||||
|
||||
<div className="px-6 py-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2">
|
||||
{t('tasks.comments')} · {comments?.length ?? 0}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{(comments ?? []).map((c) => (
|
||||
<div key={c.id} className="flex gap-3">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-medium">
|
||||
{c.author.split(/\s+/).slice(0, 2).map((w) => w[0]?.toUpperCase() ?? '').join('')}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-medium">{c.author}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{formatRelative(c.createdAt)}</span>
|
||||
</div>
|
||||
<div className="rounded-md bg-background/60 px-3 py-2 text-sm">{c.content}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteComment.mutate({ id: c.id })}
|
||||
className="mt-1 text-[10px] text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sticky composer */}
|
||||
<div className="px-6 py-3 border-t border-border/40 shrink-0">
|
||||
<div className="rounded-2xl bg-background/70 backdrop-blur-xl border border-border/50 shadow-lg ring-1 ring-border/20 transition-shadow focus-within:shadow-xl focus-within:border-ring/50">
|
||||
<ChatInputBox
|
||||
cacheKey={`task-comment-${liveTask.id}`}
|
||||
isStreaming={false}
|
||||
variant="comment"
|
||||
placeholder={t('tasks.writeComment')}
|
||||
onSend={(text) => addComment.mutate({ taskId: liveTask.id, author: 'Me', content: text })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function PropRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">{label}</div>
|
||||
<div className="text-sm">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
627
src/renderer/components/tasks/TaskFormDialog.tsx
Normal file
627
src/renderer/components/tasks/TaskFormDialog.tsx
Normal file
@@ -0,0 +1,627 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Folder, ArrowUp, ArrowRight, ArrowDown, Circle, Clock, CheckCircle2, Calendar as CalIcon, UserPlus, Plus, Paperclip } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { PropertyPill } from './PropertyPill';
|
||||
import { InlineProjectForm } from './InlineProjectForm';
|
||||
import { useFormatPrefs, formatDueDate } from '@/lib/date';
|
||||
import { useTaskAttachments } from './useTaskAttachments';
|
||||
import { useRovingFocus } from '@/hooks/useRovingFocus';
|
||||
import { useListboxKeys } from '@/hooks/useListboxKeys';
|
||||
import { DateTimeField } from '@/components/ui/datetime-field';
|
||||
|
||||
export type TaskFormValues = {
|
||||
title: string;
|
||||
description: string;
|
||||
priority: string;
|
||||
status: string;
|
||||
dueDate: number | null;
|
||||
projectId: string | null;
|
||||
assignees: string[];
|
||||
estimate: number | null;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode: 'create' | 'edit';
|
||||
taskId?: string;
|
||||
initialValues?: Partial<TaskFormValues>;
|
||||
onSubmit: (values: TaskFormValues) => void;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULTS: TaskFormValues = {
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
status: 'todo',
|
||||
dueDate: null,
|
||||
projectId: null,
|
||||
assignees: [],
|
||||
estimate: null,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components (defined outside TaskFormDialog to avoid recreation on render)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ProjectList({
|
||||
projects,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onClose,
|
||||
}: {
|
||||
projects: { id: string; name: string }[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
onCreate: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Item layout: [0] "+ New project", [1] "No project", [2..N+1] projects
|
||||
const items = [
|
||||
{ kind: 'new' as const },
|
||||
{ kind: 'none' as const },
|
||||
...projects.map((p) => ({ kind: 'project' as const, id: p.id, name: p.name })),
|
||||
];
|
||||
const listbox = useListboxKeys({
|
||||
itemCount: items.length,
|
||||
initialIndex: selectedId
|
||||
? Math.max(0, items.findIndex((it) => it.kind === 'project' && it.id === selectedId))
|
||||
: 1,
|
||||
onSelect: (i) => {
|
||||
const it = items[i];
|
||||
if (it.kind === 'new') onCreate();
|
||||
else if (it.kind === 'none') onSelect(null);
|
||||
else onSelect(it.id);
|
||||
},
|
||||
onClose,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
listbox.focusIndex(listbox.activeIndex);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const base =
|
||||
'w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none flex items-center gap-1.5';
|
||||
|
||||
return (
|
||||
<div className="p-1" role="listbox" aria-label={t('tasks.project')}>
|
||||
{items.map((it, i) => {
|
||||
const itemProps = listbox.getItemProps(i);
|
||||
if (it.kind === 'new') {
|
||||
return (
|
||||
<button
|
||||
key="new"
|
||||
type="button"
|
||||
{...itemProps}
|
||||
role="option"
|
||||
className={base + ' text-primary'}
|
||||
onClick={() => onCreate()}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t('projects.newProject')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (it.kind === 'none') {
|
||||
return (
|
||||
<button
|
||||
key="none"
|
||||
type="button"
|
||||
{...itemProps}
|
||||
role="option"
|
||||
className={base}
|
||||
onClick={() => onSelect(null)}
|
||||
>
|
||||
{t('tasks.noProject')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={it.id}
|
||||
type="button"
|
||||
{...itemProps}
|
||||
role="option"
|
||||
className={base}
|
||||
onClick={() => onSelect(it.id)}
|
||||
>
|
||||
{it.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PriorityList({
|
||||
value,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
value: string;
|
||||
onSelect: (v: 'high' | 'medium' | 'low') => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const items = ['high', 'medium', 'low'] as const;
|
||||
const initial = Math.max(0, items.indexOf(value as (typeof items)[number]));
|
||||
const listbox = useListboxKeys({
|
||||
itemCount: items.length,
|
||||
initialIndex: initial,
|
||||
onSelect: (i) => onSelect(items[i]),
|
||||
onClose,
|
||||
});
|
||||
useEffect(() => {
|
||||
listbox.focusIndex(listbox.activeIndex);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return (
|
||||
<div role="listbox" aria-label={t('tasks.priority')} className="p-1">
|
||||
{items.map((p, i) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
{...listbox.getItemProps(i)}
|
||||
role="option"
|
||||
aria-selected={value === p}
|
||||
onClick={() => onSelect(p)}
|
||||
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none"
|
||||
>
|
||||
{t(`tasks.${p}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusList({
|
||||
value,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
value: string;
|
||||
onSelect: (v: 'todo' | 'in_progress' | 'done') => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const items = ['todo', 'in_progress', 'done'] as const;
|
||||
const initial = Math.max(0, items.indexOf(value as (typeof items)[number]));
|
||||
const listbox = useListboxKeys({
|
||||
itemCount: items.length,
|
||||
initialIndex: initial,
|
||||
onSelect: (i) => onSelect(items[i]),
|
||||
onClose,
|
||||
});
|
||||
useEffect(() => {
|
||||
listbox.focusIndex(listbox.activeIndex);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return (
|
||||
<div role="listbox" aria-label={t('tasks.status')} className="p-1">
|
||||
{items.map((s, i) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
{...listbox.getItemProps(i)}
|
||||
role="option"
|
||||
aria-selected={value === s}
|
||||
onClick={() => onSelect(s)}
|
||||
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none"
|
||||
>
|
||||
{t(s === 'todo' ? 'tasks.toDo' : s === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssigneesList({
|
||||
known,
|
||||
selected,
|
||||
onToggle,
|
||||
onClose,
|
||||
newName,
|
||||
onNewNameChange,
|
||||
onAddNew,
|
||||
}: {
|
||||
known: string[];
|
||||
selected: string[];
|
||||
onToggle: (name: string) => void;
|
||||
onClose: () => void;
|
||||
newName: string;
|
||||
onNewNameChange: (s: string) => void;
|
||||
onAddNew: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const listbox = useListboxKeys({
|
||||
itemCount: known.length,
|
||||
initialIndex: 0,
|
||||
onSelect: (i) => onToggle(known[i]),
|
||||
onClose,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (known.length > 0) listbox.focusIndex(0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return (
|
||||
<div className="p-2" role="listbox" aria-multiselectable="true" aria-label={t('tasks.assignees')}>
|
||||
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
|
||||
{known.map((name, i) => {
|
||||
const isOn = selected.includes(name);
|
||||
const itemProps = listbox.getItemProps(i);
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
{...itemProps}
|
||||
ref={(el) => {
|
||||
itemRefs.current[i] = el;
|
||||
itemProps.ref(el);
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={isOn}
|
||||
onClick={() => onToggle(name)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown' && i === known.length - 1) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
itemProps.onKeyDown(e);
|
||||
}}
|
||||
className="text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none"
|
||||
>
|
||||
{isOn ? '✓ ' : ' '}{name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="border-t mt-2 pt-2 flex gap-1.5">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={t('tasks.newAssigneeName', 'New name…')}
|
||||
value={newName}
|
||||
onChange={(e) => onNewNameChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onAddNew();
|
||||
} else if (e.key === 'ArrowUp' && known.length > 0) {
|
||||
e.preventDefault();
|
||||
itemRefs.current[known.length - 1]?.focus();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
className="h-8 text-sm flex-1"
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={onAddNew} disabled={!newName.trim()}>
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TaskFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
mode,
|
||||
taskId,
|
||||
initialValues,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [values, setValues] = useState<TaskFormValues>({ ...DEFAULTS, ...initialValues });
|
||||
const [projectPopoverOpen, setProjectPopoverOpen] = useState(false);
|
||||
const [priorityOpen, setPriorityOpen] = useState(false);
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
const [assigneesOpen, setAssigneesOpen] = useState(false);
|
||||
const [dueOpen, setDueOpen] = useState(false);
|
||||
const [creatingProject, setCreatingProject] = useState(false);
|
||||
const [assigneeInput, setAssigneeInput] = useState('');
|
||||
|
||||
const PILL_COUNT = 5; // Project, Priority, Status, Due, Assignees
|
||||
const pillsRoving = useRovingFocus({ count: PILL_COUNT, direction: 'both' });
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValues({ ...DEFAULTS, ...initialValues });
|
||||
setCreatingProject(false);
|
||||
setAssigneeInput('');
|
||||
setProjectPopoverOpen(false);
|
||||
setPriorityOpen(false);
|
||||
setStatusOpen(false);
|
||||
setAssigneesOpen(false);
|
||||
setDueOpen(false);
|
||||
}
|
||||
}, [open, initialValues]);
|
||||
|
||||
function addNewAssignee() {
|
||||
const name = assigneeInput.trim();
|
||||
if (!name) return;
|
||||
setValues((v) => (v.assignees.includes(name) ? v : { ...v, assignees: [...v.assignees, name] }));
|
||||
setAssigneeInput('');
|
||||
}
|
||||
|
||||
const handleDueChange = useCallback((d: Date | undefined) => {
|
||||
setValues((v) => ({ ...v, dueDate: d ? d.getTime() : null }));
|
||||
}, []);
|
||||
const handleDueCommit = useCallback(() => {
|
||||
setDueOpen(false);
|
||||
}, []);
|
||||
|
||||
const { data: projectsList = [] } = trpc.projects.listAll.useQuery();
|
||||
const { data: knownAssignees = [] } = trpc.tasks.listAssignees.useQuery();
|
||||
const prefs = useFormatPrefs();
|
||||
const selectedProject = projectsList.find((p) => p.id === values.projectId);
|
||||
const attachments = useTaskAttachments(mode === 'edit' && taskId ? taskId : null);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!values.title.trim()) return;
|
||||
onSubmit({ ...values, title: values.title.trim() });
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[580px] p-0 gap-0 overflow-hidden bg-card/92 backdrop-blur-xl">
|
||||
<DialogHeader className="px-5 pt-5 pb-2">
|
||||
<DialogTitle>
|
||||
{mode === 'create' ? t('tasks.newTask') : t('tasks.editTask')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === 'create' ? t('tasks.newTaskDescription') : t('tasks.editTaskDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') handleSubmit(e);
|
||||
}}
|
||||
>
|
||||
<div className="px-5 pt-5 pb-2">
|
||||
<input
|
||||
autoFocus
|
||||
className="w-full bg-transparent border-none outline-none text-[22px] font-medium leading-tight placeholder:text-muted-foreground/60"
|
||||
placeholder={t('tasks.whatNeedsToBeDone')}
|
||||
value={values.title}
|
||||
onChange={(e) => setValues((v) => ({ ...v, title: e.target.value }))}
|
||||
/>
|
||||
<textarea
|
||||
className="mt-2 w-full bg-transparent border-none outline-none text-sm resize-none placeholder:text-muted-foreground/60"
|
||||
rows={3}
|
||||
placeholder={t('tasks.descriptionOptional')}
|
||||
value={values.description}
|
||||
onChange={(e) => setValues((v) => ({ ...v, description: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pb-3 pt-1">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2">
|
||||
{t('tasks.properties')}
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
data-testid="property-pills"
|
||||
role="toolbar"
|
||||
aria-label={t('tasks.properties')}
|
||||
>
|
||||
{/* Project */}
|
||||
<Popover
|
||||
open={projectPopoverOpen}
|
||||
onOpenChange={(o) => {
|
||||
setProjectPopoverOpen(o);
|
||||
if (!o) setCreatingProject(false);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<PropertyPill
|
||||
{...pillsRoving.getItemProps(0)}
|
||||
icon={<Folder className="h-3 w-3" />}
|
||||
label={t('tasks.project')}
|
||||
value={selectedProject?.name ?? null}
|
||||
empty={!selectedProject}
|
||||
aria-label={t('tasks.project') + (selectedProject ? `: ${selectedProject.name}` : '')}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-0 max-h-96 overflow-y-auto" align="start">
|
||||
{projectPopoverOpen && (creatingProject ? (
|
||||
<InlineProjectForm
|
||||
onCancel={() => setCreatingProject(false)}
|
||||
onCreated={(id) => {
|
||||
setValues((v) => ({ ...v, projectId: id }));
|
||||
setCreatingProject(false);
|
||||
setProjectPopoverOpen(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ProjectList
|
||||
projects={projectsList}
|
||||
selectedId={values.projectId}
|
||||
onSelect={(id) => {
|
||||
setValues((v) => ({ ...v, projectId: id }));
|
||||
setProjectPopoverOpen(false);
|
||||
}}
|
||||
onCreate={() => setCreatingProject(true)}
|
||||
onClose={() => setProjectPopoverOpen(false)}
|
||||
/>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Priority */}
|
||||
<Popover open={priorityOpen} onOpenChange={setPriorityOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<PropertyPill
|
||||
{...pillsRoving.getItemProps(1)}
|
||||
icon={
|
||||
values.priority === 'high' ? <ArrowUp className="h-3 w-3 text-red-600" /> :
|
||||
values.priority === 'low' ? <ArrowDown className="h-3 w-3 text-muted-foreground" /> :
|
||||
<ArrowRight className="h-3 w-3 text-amber-600" />
|
||||
}
|
||||
label={t('tasks.priority')}
|
||||
value={t(`tasks.${values.priority}`)}
|
||||
aria-label={t('tasks.priority') + `: ${t(`tasks.${values.priority}`)}`}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-40 p-0" align="start">
|
||||
{priorityOpen && (
|
||||
<PriorityList
|
||||
value={values.priority}
|
||||
onSelect={(p) => {
|
||||
setValues((v) => ({ ...v, priority: p }));
|
||||
setPriorityOpen(false);
|
||||
}}
|
||||
onClose={() => setPriorityOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Status */}
|
||||
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<PropertyPill
|
||||
{...pillsRoving.getItemProps(2)}
|
||||
icon={
|
||||
values.status === 'in_progress' ? <Clock className="h-3 w-3" /> :
|
||||
values.status === 'done' ? <CheckCircle2 className="h-3 w-3" /> :
|
||||
<Circle className="h-3 w-3" />
|
||||
}
|
||||
label={t('tasks.status')}
|
||||
value={t(values.status === 'todo' ? 'tasks.toDo' : values.status === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}
|
||||
aria-label={t('tasks.status') + `: ${t(values.status === 'todo' ? 'tasks.toDo' : values.status === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}`}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-40 p-0" align="start">
|
||||
{statusOpen && (
|
||||
<StatusList
|
||||
value={values.status}
|
||||
onSelect={(s) => {
|
||||
setValues((v) => ({ ...v, status: s }));
|
||||
setStatusOpen(false);
|
||||
}}
|
||||
onClose={() => setStatusOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Due date */}
|
||||
<Popover open={dueOpen} onOpenChange={setDueOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<PropertyPill
|
||||
{...pillsRoving.getItemProps(3)}
|
||||
icon={<CalIcon className="h-3 w-3" />}
|
||||
label={t('tasks.colDue')}
|
||||
value={values.dueDate ? formatDueDate(values.dueDate, prefs) : null}
|
||||
empty={!values.dueDate}
|
||||
aria-label={t('tasks.colDue')}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-3" align="start">
|
||||
<DateTimeField
|
||||
withTime
|
||||
value={values.dueDate ? new Date(values.dueDate) : undefined}
|
||||
onChange={handleDueChange}
|
||||
onCommit={handleDueCommit}
|
||||
aria-label={t('tasks.colDue')}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Assignees */}
|
||||
<Popover open={assigneesOpen} onOpenChange={setAssigneesOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<PropertyPill
|
||||
{...pillsRoving.getItemProps(4)}
|
||||
icon={<UserPlus className="h-3 w-3" />}
|
||||
label={t('tasks.assignees')}
|
||||
value={values.assignees.length > 0 ? values.assignees.join(', ') : null}
|
||||
empty={values.assignees.length === 0}
|
||||
aria-label={t('tasks.assignees') + (values.assignees.length > 0 ? `: ${values.assignees.join(', ')}` : '')}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-0" align="start">
|
||||
{assigneesOpen && (
|
||||
<AssigneesList
|
||||
known={knownAssignees}
|
||||
selected={values.assignees}
|
||||
onToggle={(name) =>
|
||||
setValues((v) => ({
|
||||
...v,
|
||||
assignees: v.assignees.includes(name)
|
||||
? v.assignees.filter((a) => a !== name)
|
||||
: [...v.assignees, name],
|
||||
}))
|
||||
}
|
||||
onClose={() => setAssigneesOpen(false)}
|
||||
newName={assigneeInput}
|
||||
onNewNameChange={setAssigneeInput}
|
||||
onAddNew={addNewAssignee}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 px-5 py-3 border-t border-border/40 bg-background/30">
|
||||
<div>
|
||||
{mode === 'edit' && taskId && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => attachments.addFiles()}
|
||||
title={t('tasks.addFile')}
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" size="sm" disabled={!values.title.trim() || isSubmitting}>
|
||||
{isSubmitting
|
||||
? t('common.saving')
|
||||
: mode === 'create'
|
||||
? t('tasks.createTask')
|
||||
: t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
194
src/renderer/components/tasks/TaskListView.tsx
Normal file
194
src/renderer/components/tasks/TaskListView.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Search, List, LayoutGrid, ClipboardCheck } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
|
||||
import { TaskTable } from './TaskTable';
|
||||
import { TaskCard } from './TaskCard';
|
||||
import { TaskPager } from './TaskPager';
|
||||
import { TaskDetailSheet } from './TaskDetailSheet';
|
||||
import { NewTaskDialog } from './NewTaskDialog';
|
||||
import { EditTaskDialog } from './EditTaskDialog';
|
||||
import { type TaskItem } from './task-types';
|
||||
|
||||
type StatusFilter = 'active' | 'todo' | 'in_progress' | 'all' | 'done';
|
||||
type OrderBy = 'dueDate' | 'priority' | 'createdAt';
|
||||
|
||||
const PAGE_SIZE_KEY = 'tasksPageSize';
|
||||
const VIEW_MODE_KEY = 'tasksViewMode';
|
||||
|
||||
function readPageSize(): number {
|
||||
const v = Number(localStorage.getItem(PAGE_SIZE_KEY));
|
||||
return [10, 25, 50, 100].includes(v) ? v : 25;
|
||||
}
|
||||
|
||||
function readViewMode(): 'list' | 'grid' {
|
||||
return (localStorage.getItem(VIEW_MODE_KEY) as 'list' | 'grid') ?? 'list';
|
||||
}
|
||||
|
||||
export function TaskListView({
|
||||
projectId,
|
||||
hideProjectColumn,
|
||||
}: {
|
||||
projectId?: string;
|
||||
hideProjectColumn?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const utils = trpc.useUtils();
|
||||
const { notify, notifyError } = useNotify();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('active');
|
||||
const [orderBy, setOrderBy] = useState<OrderBy>('dueDate');
|
||||
const [viewMode, setViewMode] = useState<'list' | 'grid'>(readViewMode);
|
||||
const [pageSize, setPageSize] = useState<number>(readPageSize);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [editTask, setEditTask] = useState<TaskItem | null>(null);
|
||||
const [viewTask, setViewTask] = useState<TaskItem | null>(null);
|
||||
|
||||
useEffect(() => { localStorage.setItem(VIEW_MODE_KEY, viewMode); }, [viewMode]);
|
||||
useEffect(() => { localStorage.setItem(PAGE_SIZE_KEY, String(pageSize)); }, [pageSize]);
|
||||
|
||||
// Reset page on any filter change
|
||||
useEffect(() => { setPageIndex(0); }, [debouncedSearch, statusFilter, orderBy]);
|
||||
|
||||
// Search debounce
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebouncedSearch(search), 300);
|
||||
return () => clearTimeout(id);
|
||||
}, [search]);
|
||||
|
||||
const backendStatus = statusFilter === 'todo' || statusFilter === 'in_progress' || statusFilter === 'done'
|
||||
? statusFilter : undefined;
|
||||
|
||||
const queryInput = useMemo(() => ({
|
||||
...(backendStatus ? { status: backendStatus } : {}),
|
||||
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
|
||||
...(projectId ? { projectId } : {}),
|
||||
orderBy,
|
||||
}), [backendStatus, debouncedSearch, orderBy, projectId]);
|
||||
|
||||
const { data: filteredTasks } = trpc.tasks.list.useQuery(queryInput);
|
||||
|
||||
const updateTask = trpc.tasks.update.useMutation({
|
||||
onSuccess: () => void utils.tasks.list.invalidate(),
|
||||
onError: (err) => notifyError('toast.task.updateError', err),
|
||||
});
|
||||
const deleteTask = trpc.tasks.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
notify('warning', 'toast.task.deleted');
|
||||
void utils.tasks.list.invalidate();
|
||||
},
|
||||
onError: (err) => notifyError('toast.task.deleteError', err),
|
||||
});
|
||||
|
||||
const tasksAll = (filteredTasks ?? [])
|
||||
.filter((task) => statusFilter !== 'active' || task.status === 'todo' || task.status === 'in_progress');
|
||||
|
||||
const total = tasksAll.length;
|
||||
const lastPage = Math.max(0, Math.ceil(total / pageSize) - 1);
|
||||
const safePageIndex = Math.min(pageIndex, lastPage);
|
||||
if (safePageIndex !== pageIndex) setPageIndex(safePageIndex);
|
||||
const pageTasks = tasksAll.slice(safePageIndex * pageSize, (safePageIndex + 1) * pageSize);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="active">{t('tasks.active')}</TabsTrigger>
|
||||
<TabsTrigger value="todo">{t('tasks.toDo')}</TabsTrigger>
|
||||
<TabsTrigger value="in_progress">{t('tasks.inProgress')}</TabsTrigger>
|
||||
<TabsTrigger value="done">{t('tasks.done')}</TabsTrigger>
|
||||
<TabsTrigger value="all">{t('tasks.all')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<InputGroup className="w-56">
|
||||
<InputGroupAddon><Search /></InputGroupAddon>
|
||||
<InputGroupInput placeholder={t('tasks.searchPlaceholder')} value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</InputGroup>
|
||||
<Select value={orderBy} onValueChange={(v) => setOrderBy(v as OrderBy)}>
|
||||
<SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="dueDate">{t('tasks.orderByDue')}</SelectItem>
|
||||
<SelectItem value="priority">{t('tasks.orderByPriority')}</SelectItem>
|
||||
<SelectItem value="createdAt">{t('tasks.orderByCreated')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ToggleGroup type="single" value={viewMode} onValueChange={(v) => v && setViewMode(v as 'list' | 'grid')} variant="outline" size="sm">
|
||||
<ToggleGroupItem value="list" aria-label={t('tasks.viewList')}><List /></ToggleGroupItem>
|
||||
<ToggleGroupItem value="grid" aria-label={t('tasks.viewGrid')}><LayoutGrid /></ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Button size="sm" onClick={() => setNewOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />{t('tasks.newTask')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
{total === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon"><ClipboardCheck /></EmptyMedia>
|
||||
<EmptyTitle>{t('tasks.noTasksFound')}</EmptyTitle>
|
||||
<EmptyDescription>{t('tasks.noTasksDescription')}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{pageTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onStatusChange={(id, status) => updateTask.mutate({ id, status })}
|
||||
onEdit={setEditTask}
|
||||
onDelete={(id) => deleteTask.mutate({ id })}
|
||||
onClick={setViewTask}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<TaskTable
|
||||
tasks={pageTasks}
|
||||
hideProjectColumn={hideProjectColumn}
|
||||
onRowClick={setViewTask}
|
||||
onEdit={setEditTask}
|
||||
onDelete={(id) => deleteTask.mutate({ id })}
|
||||
onStatusChange={(id, status) => updateTask.mutate({ id, status })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pager (always visible when there are tasks) */}
|
||||
{total > 0 && (
|
||||
<TaskPager
|
||||
total={total}
|
||||
pageIndex={safePageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPageIndex}
|
||||
onPageSizeChange={(s) => { setPageSize(s); setPageIndex(0); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NewTaskDialog open={newOpen} onOpenChange={setNewOpen} defaultProjectId={projectId} />
|
||||
<EditTaskDialog task={editTask} open={!!editTask} onOpenChange={(o) => { if (!o) setEditTask(null); }} />
|
||||
<TaskDetailSheet
|
||||
task={viewTask}
|
||||
open={!!viewTask}
|
||||
onOpenChange={(o) => { if (!o) setViewTask(null); }}
|
||||
onEdit={(task) => { setViewTask(null); setEditTask(task); }}
|
||||
onDelete={(id) => { deleteTask.mutate({ id }); setViewTask(null); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
src/renderer/components/tasks/TaskPager.tsx
Normal file
100
src/renderer/components/tasks/TaskPager.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
total: number;
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZES = [10, 25, 50, 100];
|
||||
|
||||
function buildWindow(current: number, last: number, max: number): Array<number | 'ellipsis'> {
|
||||
if (last <= 0) return [0];
|
||||
if (last < max) return Array.from({ length: last + 1 }, (_, i) => i);
|
||||
const window: Array<number | 'ellipsis'> = [];
|
||||
const halfMax = Math.floor((max - 2) / 2);
|
||||
let start = Math.max(1, current - halfMax);
|
||||
let end = Math.min(last - 1, current + halfMax);
|
||||
if (current - halfMax < 1) end = Math.min(last - 1, max - 2);
|
||||
if (current + halfMax > last - 1) start = Math.max(1, last - (max - 2));
|
||||
window.push(0);
|
||||
if (start > 1) window.push('ellipsis');
|
||||
for (let i = start; i <= end; i++) window.push(i);
|
||||
if (end < last - 1) window.push('ellipsis');
|
||||
window.push(last);
|
||||
return window;
|
||||
}
|
||||
|
||||
export function TaskPager({ total, pageIndex, pageSize, onPageChange, onPageSizeChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [maxButtons, setMaxButtons] = useState(7);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
const w = entry.contentRect.width;
|
||||
setMaxButtons(w < 480 ? 3 : w < 640 ? 5 : 7);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const lastPage = Math.max(0, Math.ceil(total / pageSize) - 1);
|
||||
const start = total === 0 ? 0 : pageIndex * pageSize + 1;
|
||||
const end = Math.min(total, (pageIndex + 1) * pageSize);
|
||||
const window = buildWindow(pageIndex, lastPage, maxButtons);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="rounded-lg border border-border/50 bg-card/65 backdrop-blur-xl shadow-sm flex items-center justify-between px-4 py-2 gap-3 flex-wrap"
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<Trans
|
||||
i18nKey="tasks.showingNofM"
|
||||
values={{ start, end, total }}
|
||||
components={{ b: <span className="font-medium text-foreground" /> }}
|
||||
/>
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{t('tasks.rowsPerPage')}</span>
|
||||
<Select value={String(pageSize)} onValueChange={(v) => onPageSizeChange(Number(v))}>
|
||||
<SelectTrigger className="h-7 w-[68px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZES.map((s) => <SelectItem key={s} value={String(s)}>{s}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" disabled={pageIndex === 0} onClick={() => onPageChange(pageIndex - 1)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
{window.map((p, i) =>
|
||||
p === 'ellipsis' ? (
|
||||
<span key={`e${i}`} className="px-1 text-muted-foreground">…</span>
|
||||
) : (
|
||||
<Button
|
||||
key={p}
|
||||
variant={p === pageIndex ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className={cn('h-7 min-w-7 px-2 text-xs')}
|
||||
onClick={() => onPageChange(p)}
|
||||
>
|
||||
{p + 1}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" disabled={pageIndex >= lastPage} onClick={() => onPageChange(pageIndex + 1)}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,23 +20,7 @@ import {
|
||||
import { PriorityBadge } from './PriorityBadge';
|
||||
import { useFormatPrefs, formatDueDate } from '@/lib/date';
|
||||
import { parseAssignees } from './task-utils';
|
||||
|
||||
export type TaskItem = {
|
||||
id: string;
|
||||
projectId: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: string | null;
|
||||
priority: string | null;
|
||||
assignee: string | null;
|
||||
dueDate: number | null;
|
||||
isAiSuggested: number;
|
||||
projectName: string | null;
|
||||
clientName: string | null;
|
||||
subClientName: string | null;
|
||||
};
|
||||
|
||||
export { parseAssignees } from './task-utils';
|
||||
import type { TaskItem } from './task-types';
|
||||
|
||||
export function TaskRow({
|
||||
task,
|
||||
|
||||
62
src/renderer/components/tasks/TaskTable.tsx
Normal file
62
src/renderer/components/tasks/TaskTable.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
} from '@/components/ui/table';
|
||||
import { useFormatPrefs } from '@/lib/date';
|
||||
import { type TaskItem } from './task-types';
|
||||
import { TaskTableRow } from './TaskTableRow';
|
||||
|
||||
interface Props {
|
||||
tasks: TaskItem[];
|
||||
hideProjectColumn?: boolean;
|
||||
onRowClick: (task: TaskItem) => void;
|
||||
onEdit: (task: TaskItem) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onStatusChange: (id: string, status: string) => void;
|
||||
}
|
||||
|
||||
export function TaskTable({
|
||||
tasks,
|
||||
hideProjectColumn,
|
||||
onRowClick,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onStatusChange,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const prefs = useFormatPrefs();
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/50 bg-card/65 backdrop-blur-xl shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>{t('tasks.colTask')}</TableHead>
|
||||
{!hideProjectColumn && <TableHead>{t('tasks.colProject')}</TableHead>}
|
||||
<TableHead>{t('tasks.colPriority')}</TableHead>
|
||||
<TableHead>{t('tasks.colDue')}</TableHead>
|
||||
<TableHead>{t('tasks.colAssignee')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tasks.map((task) => (
|
||||
<TaskTableRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
hideProjectColumn={hideProjectColumn}
|
||||
onClick={() => onRowClick(task)}
|
||||
onEdit={() => onEdit(task)}
|
||||
onDelete={() => onDelete(task.id)}
|
||||
onStatusChange={(s) => onStatusChange(task.id, s)}
|
||||
prefs={prefs}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
src/renderer/components/tasks/TaskTableRow.tsx
Normal file
91
src/renderer/components/tasks/TaskTableRow.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronRight, Pencil, Trash2, Check, RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuSubContent,
|
||||
} from '@/components/ui/context-menu';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatDueDate, isOverdue, type FormatPrefs } from '@/lib/date';
|
||||
import { parseAssignees } from './task-utils';
|
||||
import type { TaskItem } from './task-types';
|
||||
import { PriorityBadge } from './PriorityBadge';
|
||||
import { AssigneeStack } from './AssigneeStack';
|
||||
|
||||
const STATUSES = ['todo', 'in_progress', 'done'] as const;
|
||||
|
||||
export function TaskTableRow({
|
||||
task,
|
||||
hideProjectColumn,
|
||||
onClick,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onStatusChange,
|
||||
prefs,
|
||||
}: {
|
||||
task: TaskItem;
|
||||
hideProjectColumn?: boolean;
|
||||
onClick: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onStatusChange: (status: string) => void;
|
||||
prefs: FormatPrefs;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const assignees = parseAssignees(task.assignee);
|
||||
const overdue = task.dueDate ? isOverdue(task.dueDate) && task.status !== 'done' : false;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<TableRow className="cursor-pointer" onClick={onClick}>
|
||||
<TableCell className="font-medium max-w-[280px] truncate">{task.title}</TableCell>
|
||||
{!hideProjectColumn && (
|
||||
<TableCell className="text-xs">
|
||||
{task.clientName && <span className="text-muted-foreground">{task.clientName}</span>}
|
||||
{task.clientName && task.projectName && (
|
||||
<ChevronRight className="inline h-3 w-3 mx-1 text-muted-foreground" />
|
||||
)}
|
||||
{task.projectName && <span>{task.projectName}</span>}
|
||||
{!task.projectName && <span className="text-muted-foreground">—</span>}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell><PriorityBadge priority={task.priority} /></TableCell>
|
||||
<TableCell className={cn(overdue && 'text-red-600 dark:text-red-400')}>
|
||||
{task.dueDate ? formatDueDate(task.dueDate, prefs) : <span className="text-muted-foreground">—</span>}
|
||||
</TableCell>
|
||||
<TableCell><AssigneeStack assignees={assignees} /></TableCell>
|
||||
</TableRow>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={onEdit}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
{t('common.edit')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
{t('tasks.changeStatus')}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
{STATUSES.map((s) => (
|
||||
<ContextMenuItem key={s} onSelect={() => onStatusChange(s)}>
|
||||
{task.status === s ? <Check className="h-3 w-3 mr-2" /> : <span className="w-5" />}
|
||||
{t(s === 'todo' ? 'tasks.toDo' : s === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuItem onSelect={onDelete} className="text-destructive focus:text-destructive">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t('common.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
16
src/renderer/components/tasks/task-types.ts
Normal file
16
src/renderer/components/tasks/task-types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type TaskItem = {
|
||||
id: string;
|
||||
projectId: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: string | null;
|
||||
priority: string | null;
|
||||
assignee: string | null;
|
||||
dueDate: number | null;
|
||||
estimate: number | null;
|
||||
isAiSuggested: number;
|
||||
projectName: string | null;
|
||||
clientName: string | null;
|
||||
subClientName: string | null;
|
||||
createdAt: number | null;
|
||||
};
|
||||
42
src/renderer/components/tasks/useTaskAttachments.ts
Normal file
42
src/renderer/components/tasks/useTaskAttachments.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
|
||||
const MAX_SIZE = 50 * 1024 * 1024;
|
||||
|
||||
export function useTaskAttachments(taskId: string | null) {
|
||||
const utils = trpc.useUtils();
|
||||
const { notify, notifyError } = useNotify();
|
||||
|
||||
const list = trpc.taskAttachments.list.useQuery(
|
||||
{ taskId: taskId ?? '' },
|
||||
{ enabled: !!taskId },
|
||||
);
|
||||
const pick = trpc.taskAttachments.pick.useMutation();
|
||||
const create = trpc.taskAttachments.create.useMutation({
|
||||
onSuccess: () => taskId && void utils.taskAttachments.list.invalidate({ taskId }),
|
||||
onError: (err) => notifyError('toast.attachment.createError', err),
|
||||
});
|
||||
const remove = trpc.taskAttachments.delete.useMutation({
|
||||
onSuccess: () => taskId && void utils.taskAttachments.list.invalidate({ taskId }),
|
||||
});
|
||||
const open = trpc.taskAttachments.open.useMutation();
|
||||
|
||||
async function addFiles() {
|
||||
if (!taskId) return;
|
||||
const picked = await pick.mutateAsync();
|
||||
for (const f of picked) {
|
||||
if (f.size > MAX_SIZE) {
|
||||
notify('warning', 'toast.attachment.tooLarge', { values: { filename: f.name } });
|
||||
continue;
|
||||
}
|
||||
await create.mutateAsync({
|
||||
taskId,
|
||||
sourcePath: f.path,
|
||||
filename: f.name,
|
||||
sizeBytes: f.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { list, addFiles, remove, open };
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFormatPrefs, formatDate } from '@/lib/date';
|
||||
import { CalendarIcon, Check } from 'lucide-react';
|
||||
import { type DateRange } from 'react-day-picker';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
@@ -21,10 +21,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { DateField } from '@/components/ui/date-field';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TimelineEventType } from './ProjectTimeline';
|
||||
import type { HistoryEntry } from './history-types';
|
||||
|
||||
@@ -35,218 +35,414 @@ interface AddEventDialogProps {
|
||||
onRecordHistory?: (entry: HistoryEntry) => void;
|
||||
}
|
||||
|
||||
interface AddedEntry {
|
||||
type StagedEvent = {
|
||||
id: string;
|
||||
title: string;
|
||||
type: TimelineEventType;
|
||||
date: Date;
|
||||
endDate?: Date;
|
||||
type: TimelineEventType;
|
||||
};
|
||||
|
||||
type Mode = { kind: 'add' } | { kind: 'edit'; id: string };
|
||||
|
||||
function newLocalId(): string {
|
||||
return 'staged_' + Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
export function AddEventDialog({ open, onOpenChange, defaultProjectId, onRecordHistory }: AddEventDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const prefs = useFormatPrefs();
|
||||
const { notify, notifyError } = useNotify();
|
||||
|
||||
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
|
||||
const [staged, setStaged] = useState<StagedEvent[]>([]);
|
||||
const [mode, setMode] = useState<Mode>({ kind: 'add' });
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [type, setType] = useState<TimelineEventType>('milestone');
|
||||
const [dateRange, setDateRange] = useState<DateRange | undefined>();
|
||||
const [singleDate, setSingleDate] = useState<Date | undefined>();
|
||||
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
|
||||
const [added, setAdded] = useState<AddedEntry[]>([]);
|
||||
const [date, setDate] = useState<Date | undefined>();
|
||||
const [endDate, setEndDate] = useState<Date | undefined>();
|
||||
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
const closedRef = useRef(false);
|
||||
const [focusedRowId, setFocusedRowId] = useState<string | null>(null);
|
||||
const rowRefs = useRef<Map<string, HTMLLIElement>>(new Map());
|
||||
const stagedListRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
const showProjectSelect = !defaultProjectId;
|
||||
const projectLocked = staged.length > 0;
|
||||
const isActivity = type === 'activity';
|
||||
|
||||
const { data: projectsList } = trpc.projects.listAll.useQuery(undefined, {
|
||||
enabled: showProjectSelect,
|
||||
});
|
||||
const utils = trpc.useUtils();
|
||||
const { notify, notifyError } = useNotify();
|
||||
const createEvent = trpc.timelineEvents.create.useMutation();
|
||||
|
||||
const createEvent = trpc.timelineEvents.create.useMutation({
|
||||
onSuccess: (data, variables) => {
|
||||
notify('success', 'toast.timeline.created');
|
||||
void utils.timelineEvents.list.invalidate();
|
||||
onRecordHistory?.({
|
||||
kind: 'create',
|
||||
id: data.id,
|
||||
payload: {
|
||||
id: data.id,
|
||||
projectId: variables.projectId ?? null,
|
||||
title: variables.title,
|
||||
date: variables.date,
|
||||
endDate: variables.endDate ?? null,
|
||||
type: (variables.type ?? 'milestone') as 'milestone' | 'checkpoint' | 'activity',
|
||||
isCompleted: 0,
|
||||
isAiSuggested: 0,
|
||||
},
|
||||
});
|
||||
setAdded((prev) => [
|
||||
...prev,
|
||||
{
|
||||
title: variables.title,
|
||||
date: new Date(variables.date),
|
||||
endDate: variables.endDate ? new Date(variables.endDate) : undefined,
|
||||
type: variables.type as TimelineEventType,
|
||||
},
|
||||
]);
|
||||
setTitle('');
|
||||
setDateRange(undefined);
|
||||
setSingleDate(undefined);
|
||||
},
|
||||
onError: (err) => notifyError('toast.timeline.createError', err),
|
||||
});
|
||||
function resetForm() {
|
||||
setTitle('');
|
||||
setDate(undefined);
|
||||
setEndDate(undefined);
|
||||
setMode({ kind: 'add' });
|
||||
setTimeout(() => titleRef.current?.focus(), 0);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
closedRef.current = true;
|
||||
setTitle('');
|
||||
setType('milestone');
|
||||
setDateRange(undefined);
|
||||
setSingleDate(undefined);
|
||||
setDate(undefined);
|
||||
setEndDate(undefined);
|
||||
setProjectId(defaultProjectId ?? '');
|
||||
setAdded([]);
|
||||
setStaged([]);
|
||||
setMode({ kind: 'add' });
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const pid = defaultProjectId || projectId || undefined;
|
||||
function attemptClose() {
|
||||
if (staged.length === 0) {
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
const ok = window.confirm(t('timeline.confirmCloseStaged', { count: staged.length }));
|
||||
if (ok) handleClose();
|
||||
}
|
||||
|
||||
if (isActivity) {
|
||||
if (!title.trim() || !dateRange?.from) return;
|
||||
const hasDuration = dateRange.to && dateRange.to.getTime() !== dateRange.from.getTime();
|
||||
createEvent.mutate({
|
||||
title: title.trim(),
|
||||
date: dateRange.from.getTime(),
|
||||
endDate: hasDuration ? dateRange.to!.getTime() : undefined,
|
||||
type: 'activity',
|
||||
projectId: pid,
|
||||
});
|
||||
function formValid(): boolean {
|
||||
if (!title.trim()) return false;
|
||||
if (!date) return false;
|
||||
if (isActivity && endDate && endDate < date) return false;
|
||||
if (showProjectSelect && !projectId) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function stageOrUpdate() {
|
||||
if (!formValid() || !date) return;
|
||||
const entry: StagedEvent = {
|
||||
id: mode.kind === 'edit' ? mode.id : newLocalId(),
|
||||
title: title.trim(),
|
||||
type,
|
||||
date,
|
||||
endDate: isActivity ? endDate : undefined,
|
||||
};
|
||||
if (mode.kind === 'edit') {
|
||||
setStaged((prev) => prev.map((e) => (e.id === entry.id ? entry : e)));
|
||||
} else {
|
||||
if (!title.trim() || !singleDate) return;
|
||||
createEvent.mutate({
|
||||
title: title.trim(),
|
||||
date: singleDate.getTime(),
|
||||
type,
|
||||
projectId: pid,
|
||||
});
|
||||
setStaged((prev) => [...prev, entry]);
|
||||
}
|
||||
resetForm();
|
||||
}
|
||||
|
||||
async function saveBatch() {
|
||||
if (staged.length === 0) return;
|
||||
const pid = defaultProjectId || projectId || undefined;
|
||||
closedRef.current = false;
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
staged.map((e) =>
|
||||
createEvent.mutateAsync({
|
||||
title: e.title,
|
||||
date: e.date.getTime(),
|
||||
endDate: e.endDate ? e.endDate.getTime() : undefined,
|
||||
type: e.type,
|
||||
projectId: pid,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
let okCount = 0;
|
||||
const failedIds = new Set<string>();
|
||||
results.forEach((r, i) => {
|
||||
const s = staged[i];
|
||||
if (r.status === 'fulfilled') {
|
||||
okCount += 1;
|
||||
onRecordHistory?.({
|
||||
kind: 'create',
|
||||
id: r.value.id,
|
||||
payload: {
|
||||
id: r.value.id,
|
||||
projectId: pid ?? null,
|
||||
title: s.title,
|
||||
date: s.date.getTime(),
|
||||
endDate: s.endDate ? s.endDate.getTime() : null,
|
||||
type: s.type,
|
||||
isCompleted: 0,
|
||||
isAiSuggested: 0,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
failedIds.add(s.id);
|
||||
}
|
||||
});
|
||||
|
||||
if (closedRef.current) return;
|
||||
void utils.timelineEvents.list.invalidate();
|
||||
|
||||
if (failedIds.size === 0) {
|
||||
notify('success', 'toast.timeline.batchCreated', { count: okCount });
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
if (okCount === 0) {
|
||||
const firstError = results.find((r) => r.status === 'rejected') as PromiseRejectedResult | undefined;
|
||||
notifyError('toast.timeline.batchFailed', firstError?.reason);
|
||||
} else {
|
||||
notify('warning', 'toast.timeline.batchPartial', { ok: okCount, failed: failedIds.size });
|
||||
}
|
||||
setStaged((prev) => prev.filter((e) => failedIds.has(e.id)));
|
||||
}
|
||||
|
||||
function loadRowIntoForm(row: StagedEvent) {
|
||||
setTitle(row.title);
|
||||
setType(row.type);
|
||||
setDate(row.date);
|
||||
setEndDate(row.endDate);
|
||||
setMode({ kind: 'edit', id: row.id });
|
||||
setFocusedRowId(null);
|
||||
setTimeout(() => titleRef.current?.focus(), 0);
|
||||
}
|
||||
|
||||
function removeRow(id: string) {
|
||||
const idx = staged.findIndex((s) => s.id === id);
|
||||
setStaged((prev) => prev.filter((s) => s.id !== id));
|
||||
setFocusedRowId(null);
|
||||
if (mode.kind === 'edit' && mode.id === id) {
|
||||
setMode({ kind: 'add' });
|
||||
}
|
||||
setTimeout(() => {
|
||||
const next = staged[idx + 1] ?? staged[idx - 1];
|
||||
if (next) {
|
||||
const el = rowRefs.current.get(next.id);
|
||||
if (el) {
|
||||
setFocusedRowId(next.id);
|
||||
el.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
titleRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function onRowKeyDown(e: React.KeyboardEvent<HTMLLIElement>, row: StagedEvent) {
|
||||
const idx = staged.findIndex((s) => s.id === row.id);
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const next = staged[idx + 1];
|
||||
if (next) {
|
||||
setFocusedRowId(next.id);
|
||||
rowRefs.current.get(next.id)?.focus();
|
||||
} else {
|
||||
setFocusedRowId(null);
|
||||
titleRef.current?.focus();
|
||||
}
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const prev = staged[idx - 1];
|
||||
if (prev) {
|
||||
setFocusedRowId(prev.id);
|
||||
rowRefs.current.get(prev.id)?.focus();
|
||||
}
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
loadRowIntoForm(row);
|
||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
removeRow(row.id);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setFocusedRowId(null);
|
||||
titleRef.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = isActivity ? (title.trim() && dateRange?.from) : (title.trim() && singleDate);
|
||||
function onFormKeyDown(e: React.KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void saveBatch();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
stageOrUpdate();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
attemptClose();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); else onOpenChange(v); }}>
|
||||
<DialogContent className="sm:max-w-[420px]">
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) attemptClose(); else onOpenChange(v); }}>
|
||||
<DialogContent className="sm:max-w-[440px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('timeline.addEventTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('timeline.addEventDescription')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{added.length > 0 && (
|
||||
<ScrollArea className="max-h-32">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{added.map((entry, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Check className="h-3.5 w-3.5 text-chart-2 shrink-0" />
|
||||
<span className="truncate">{entry.title}</span>
|
||||
<span className="ml-auto text-xs shrink-0">
|
||||
{entry.type === 'milestone' ? t('timeline.typeMilestone') : entry.type === 'checkpoint' ? t('timeline.typeCheckpoint') : t('timeline.typeActivity')}
|
||||
</span>
|
||||
</div>
|
||||
{showProjectSelect && (
|
||||
<Select
|
||||
value={projectId}
|
||||
onValueChange={setProjectId}
|
||||
disabled={projectLocked}
|
||||
>
|
||||
<SelectTrigger title={projectLocked ? t('timeline.projectLocked') : undefined}>
|
||||
<SelectValue placeholder={t('timeline.selectProjectOptional')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projectsList?.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{staged.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-2">{t('timeline.emptyStagedHint')}</p>
|
||||
) : (
|
||||
<ScrollArea className="max-h-40 border rounded-md">
|
||||
<ul ref={stagedListRef} className="flex flex-col" role="listbox" aria-label={t('timeline.staged', { count: staged.length })}>
|
||||
{staged.map((e) => (
|
||||
<li
|
||||
key={e.id}
|
||||
ref={(el) => {
|
||||
if (el) rowRefs.current.set(e.id, el);
|
||||
else rowRefs.current.delete(e.id);
|
||||
}}
|
||||
tabIndex={focusedRowId === e.id ? 0 : -1}
|
||||
role="option"
|
||||
aria-selected={focusedRowId === e.id}
|
||||
onKeyDown={(ev) => onRowKeyDown(ev, e)}
|
||||
onFocus={() => setFocusedRowId(e.id)}
|
||||
onBlur={(ev) => {
|
||||
const next = ev.relatedTarget as Node | null;
|
||||
if (!next || !stagedListRef.current?.contains(next)) {
|
||||
setFocusedRowId(null);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2 py-1.5 text-sm outline-none',
|
||||
focusedRowId === e.id && 'bg-accent/40',
|
||||
mode.kind === 'edit' && mode.id === e.id && 'ring-1 ring-primary/40',
|
||||
)}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5 text-chart-2 shrink-0" />
|
||||
<span className="truncate flex-1">{e.title}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{e.type === 'milestone'
|
||||
? t('timeline.typeMilestone')
|
||||
: e.type === 'checkpoint'
|
||||
? t('timeline.typeCheckpoint')
|
||||
: t('timeline.typeActivity')}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{e.endDate
|
||||
? `${formatDate(e.date.getTime(), prefs)} – ${formatDate(e.endDate.getTime(), prefs)}`
|
||||
: formatDate(e.date.getTime(), prefs)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
aria-label={t('timeline.removeRow')}
|
||||
tabIndex={-1}
|
||||
onClick={() => removeRow(e.id)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{/* Event type selector */}
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={type}
|
||||
onValueChange={(v) => { if (v) setType(v as TimelineEventType); }}
|
||||
className="justify-start"
|
||||
>
|
||||
<ToggleGroupItem value="milestone" className="text-xs px-3">{t('timeline.typeMilestone')}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="checkpoint" className="text-xs px-3">{t('timeline.typeCheckpoint')}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="activity" className="text-xs px-3">{t('timeline.typeActivity')}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-3 transition-opacity',
|
||||
focusedRowId !== null && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
onKeyDown={onFormKeyDown}
|
||||
>
|
||||
<Tabs value={type} onValueChange={(v) => setType(v as TimelineEventType)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="milestone">{t('timeline.typeMilestone')}</TabsTrigger>
|
||||
<TabsTrigger value="checkpoint">{t('timeline.typeCheckpoint')}</TabsTrigger>
|
||||
<TabsTrigger value="activity">{t('timeline.typeActivity')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<Input
|
||||
ref={titleRef}
|
||||
placeholder={t('timeline.eventTitlePlaceholder')}
|
||||
aria-label={t('timeline.eventTitlePlaceholder')}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === 'ArrowUp' &&
|
||||
staged.length > 0 &&
|
||||
(e.currentTarget.selectionStart ?? 0) === 0
|
||||
) {
|
||||
e.preventDefault();
|
||||
const last = staged[staged.length - 1];
|
||||
setFocusedRowId(last.id);
|
||||
rowRefs.current.get(last.id)?.focus();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Date picker: range for activities, single for milestone/checkpoint */}
|
||||
{isActivity ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" className="justify-start px-2.5 font-normal">
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{dateRange?.from ? (
|
||||
dateRange.to && dateRange.to.getTime() !== dateRange.from.getTime() ? (
|
||||
<>{formatDate(dateRange.from.getTime(), prefs)} – {formatDate(dateRange.to.getTime(), prefs)}</>
|
||||
) : (
|
||||
formatDate(dateRange.from.getTime(), prefs)
|
||||
)
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t('timeline.pickDateRange')}</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="range"
|
||||
defaultMonth={dateRange?.from}
|
||||
selected={dateRange}
|
||||
onSelect={setDateRange}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="flex gap-2">
|
||||
<DateField
|
||||
value={date}
|
||||
onChange={(d) => {
|
||||
setDate(d);
|
||||
if (d && endDate && endDate < d) setEndDate(undefined);
|
||||
}}
|
||||
placeholder={t('timeline.pickStart')}
|
||||
aria-label={t('timeline.pickStart')}
|
||||
className="flex-1"
|
||||
/>
|
||||
<DateField
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
minDate={date}
|
||||
placeholder={t('timeline.pickEnd')}
|
||||
aria-label={t('timeline.pickEnd')}
|
||||
invalidMessage={
|
||||
date && endDate && endDate < date ? t('timeline.endBeforeStart') : undefined
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" className="justify-start px-2.5 font-normal">
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{singleDate ? (
|
||||
formatDate(singleDate.getTime(), prefs)
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t('timeline.pickDate')}</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={singleDate}
|
||||
onSelect={setSingleDate}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<DateField
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
placeholder={t('timeline.pickDate')}
|
||||
aria-label={t('timeline.pickDate')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showProjectSelect && (
|
||||
<Select value={projectId} onValueChange={setProjectId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('timeline.selectProjectOptional')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projectsList?.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
{added.length > 0 ? t('common.done') : t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit || createEvent.isPending}>
|
||||
{added.length > 0 ? t('timeline.addAnother') : t('common.add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={attemptClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={stageOrUpdate}
|
||||
disabled={!formValid()}
|
||||
>
|
||||
{mode.kind === 'edit' ? t('timeline.update') : t('common.add')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void saveBatch()}
|
||||
disabled={staged.length === 0 || createEvent.isPending}
|
||||
>
|
||||
{t('timeline.saveAll', { count: staged.length })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFormatPrefs, formatDate } from '@/lib/date';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { type DateRange } from 'react-day-picker';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -14,8 +11,7 @@ import {
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { DateField } from '@/components/ui/date-field';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import type { TimelineEvent, TimelineEventType } from './ProjectTimeline';
|
||||
import type { HistoryEntry } from './history-types';
|
||||
@@ -28,11 +24,10 @@ interface EditEventDialogProps {
|
||||
|
||||
export function EditEventDialog({ event, onOpenChange, onRecordHistory }: EditEventDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const prefs = useFormatPrefs();
|
||||
const [title, setTitle] = useState('');
|
||||
const [type, setType] = useState<TimelineEventType>('milestone');
|
||||
const [dateRange, setDateRange] = useState<DateRange | undefined>();
|
||||
const [singleDate, setSingleDate] = useState<Date | undefined>();
|
||||
const [date, setDate] = useState<Date | undefined>();
|
||||
const [endDate, setEndDate] = useState<Date | undefined>();
|
||||
|
||||
const pendingPrevRef = useRef<HistoryEntry | null>(null);
|
||||
|
||||
@@ -42,13 +37,11 @@ export function EditEventDialog({ event, onOpenChange, onRecordHistory }: EditEv
|
||||
if (event) {
|
||||
setTitle(event.title);
|
||||
setType(event.type ?? 'milestone');
|
||||
const from = new Date(event.date);
|
||||
setDate(new Date(event.date));
|
||||
if (event.type === 'activity' && event.endDate) {
|
||||
setDateRange({ from, to: new Date(event.endDate) });
|
||||
setSingleDate(undefined);
|
||||
setEndDate(new Date(event.endDate));
|
||||
} else {
|
||||
setSingleDate(from);
|
||||
setDateRange(undefined);
|
||||
setEndDate(undefined);
|
||||
}
|
||||
}
|
||||
}, [event]);
|
||||
@@ -74,13 +67,11 @@ export function EditEventDialog({ event, onOpenChange, onRecordHistory }: EditEv
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!event || !title.trim()) return;
|
||||
if (!event || !title.trim() || !date) return;
|
||||
|
||||
if (isActivity) {
|
||||
if (!dateRange?.from) return;
|
||||
const hasDuration = dateRange.to && dateRange.to.getTime() !== dateRange.from.getTime();
|
||||
const nextDate = dateRange.from.getTime();
|
||||
const nextEndDate = hasDuration ? dateRange.to!.getTime() : null;
|
||||
const nextDate = date.getTime();
|
||||
const nextEndDate = (endDate && endDate.getTime() !== date.getTime()) ? endDate.getTime() : null;
|
||||
pendingPrevRef.current = {
|
||||
kind: 'update',
|
||||
id: event.id,
|
||||
@@ -94,8 +85,7 @@ export function EditEventDialog({ event, onOpenChange, onRecordHistory }: EditEv
|
||||
};
|
||||
updateEvent.mutate({ id: event.id, title: title.trim(), type: 'activity', date: nextDate, endDate: nextEndDate });
|
||||
} else {
|
||||
if (!singleDate) return;
|
||||
const nextDate = singleDate.getTime();
|
||||
const nextDate = date.getTime();
|
||||
pendingPrevRef.current = {
|
||||
kind: 'update',
|
||||
id: event.id,
|
||||
@@ -111,7 +101,7 @@ export function EditEventDialog({ event, onOpenChange, onRecordHistory }: EditEv
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = isActivity ? (title.trim() && dateRange?.from) : (title.trim() && singleDate);
|
||||
const canSubmit = title.trim() && date;
|
||||
|
||||
return (
|
||||
<Dialog open={!!event} onOpenChange={onOpenChange}>
|
||||
@@ -140,51 +130,30 @@ export function EditEventDialog({ event, onOpenChange, onRecordHistory }: EditEv
|
||||
/>
|
||||
|
||||
{isActivity ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" className="justify-start px-2.5 font-normal">
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{dateRange?.from ? (
|
||||
dateRange.to && dateRange.to.getTime() !== dateRange.from.getTime() ? (
|
||||
<>{formatDate(dateRange.from.getTime(), prefs)} – {formatDate(dateRange.to.getTime(), prefs)}</>
|
||||
) : (
|
||||
formatDate(dateRange.from.getTime(), prefs)
|
||||
)
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t('timeline.pickDateRange')}</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="range"
|
||||
defaultMonth={dateRange?.from}
|
||||
selected={dateRange}
|
||||
onSelect={setDateRange}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="flex gap-2">
|
||||
<DateField
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
placeholder={t('timeline.pickStart')}
|
||||
aria-label={t('timeline.pickStart')}
|
||||
className="flex-1"
|
||||
/>
|
||||
<DateField
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
minDate={date}
|
||||
placeholder={t('timeline.pickEnd')}
|
||||
aria-label={t('timeline.pickEnd')}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" className="justify-start px-2.5 font-normal">
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{singleDate ? (
|
||||
formatDate(singleDate.getTime(), prefs)
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t('timeline.pickDate')}</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={singleDate}
|
||||
onSelect={setSingleDate}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<DateField
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
placeholder={t('timeline.pickDate')}
|
||||
aria-label={t('timeline.pickDate')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
304
src/renderer/components/ui/date-field.tsx
Normal file
304
src/renderer/components/ui/date-field.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { useFormatPrefs, formatDate, type FormatPrefs } from '@/lib/date';
|
||||
import { parseDate, type DateKeywords } from '@/lib/parseDate';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
|
||||
const MINUTES = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'];
|
||||
|
||||
function formatValue(d: Date, prefs: FormatPrefs, withTime: boolean): string {
|
||||
const base = formatDate(d.getTime(), prefs);
|
||||
if (!withTime) return base;
|
||||
const h = String(d.getHours()).padStart(2, '0');
|
||||
const m = String(d.getMinutes()).padStart(2, '0');
|
||||
if (h === '00' && m === '00') return base;
|
||||
return `${base} ${h}:${m}`;
|
||||
}
|
||||
|
||||
function CalendarTimeBody({
|
||||
value,
|
||||
onChange,
|
||||
onCommit,
|
||||
withTime,
|
||||
minDate,
|
||||
onAfterPick,
|
||||
}: {
|
||||
value: Date | undefined;
|
||||
onChange: (d: Date | undefined) => void;
|
||||
onCommit?: (d: Date) => void;
|
||||
withTime: boolean;
|
||||
minDate?: Date;
|
||||
onAfterPick: () => void;
|
||||
}) {
|
||||
const dueHour = value ? String(value.getHours()).padStart(2, '0') : '';
|
||||
const dueMinute = value ? String(value.getMinutes()).padStart(2, '0') : '';
|
||||
|
||||
function applyTime(h: string, m: string) {
|
||||
if (!value) return;
|
||||
const next = new Date(value);
|
||||
next.setHours(parseInt(h, 10), parseInt(m, 10), 0, 0);
|
||||
onChange(next);
|
||||
onCommit?.(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={value}
|
||||
onSelect={(d) => {
|
||||
if (d) {
|
||||
const next = new Date(d);
|
||||
if (value && withTime) {
|
||||
next.setHours(value.getHours(), value.getMinutes(), 0, 0);
|
||||
}
|
||||
onChange(next);
|
||||
onCommit?.(next);
|
||||
}
|
||||
onAfterPick();
|
||||
}}
|
||||
disabled={minDate ? { before: minDate } : undefined}
|
||||
/>
|
||||
{withTime && (
|
||||
<div className="border-t px-3 py-2 flex items-center gap-1.5">
|
||||
<Select value={dueHour} onValueChange={(h) => applyTime(h, dueMinute || '00')} disabled={!value}>
|
||||
<SelectTrigger className="h-8 w-20 text-sm"><SelectValue placeholder="HH" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{HOURS.map((h) => <SelectItem key={h} value={h}>{h}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-muted-foreground text-sm">:</span>
|
||||
<Select
|
||||
value={dueMinute && MINUTES.includes(dueMinute) ? dueMinute : ''}
|
||||
onValueChange={(m) => applyTime(dueHour || '00', m)}
|
||||
disabled={!value}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-20 text-sm"><SelectValue placeholder="MM" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{MINUTES.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export type DateFieldProps = {
|
||||
value: Date | undefined;
|
||||
onChange: (d: Date | undefined) => void;
|
||||
onCommit?: (d: Date) => void;
|
||||
placeholder?: string;
|
||||
minDate?: Date;
|
||||
autoFocus?: boolean;
|
||||
invalidMessage?: string;
|
||||
className?: string;
|
||||
'aria-label'?: string;
|
||||
id?: string;
|
||||
withTime?: boolean;
|
||||
flat?: boolean;
|
||||
};
|
||||
|
||||
export function DateField({
|
||||
value,
|
||||
onChange,
|
||||
onCommit,
|
||||
placeholder,
|
||||
minDate,
|
||||
autoFocus,
|
||||
invalidMessage,
|
||||
className,
|
||||
id,
|
||||
withTime,
|
||||
flat,
|
||||
...rest
|
||||
}: DateFieldProps) {
|
||||
const reactId = useId();
|
||||
const fieldId = id ?? reactId;
|
||||
const errorId = `${fieldId}-error`;
|
||||
|
||||
const { t, i18n } = useTranslation();
|
||||
const prefs = useFormatPrefs();
|
||||
const [text, setText] = useState<string>(value ? formatValue(value, prefs, !!withTime) : '');
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focused) {
|
||||
setText(value ? formatValue(value, prefs, !!withTime) : '');
|
||||
setInvalid(false);
|
||||
}
|
||||
}, [value, focused, prefs, withTime]);
|
||||
|
||||
function getKeywords(): DateKeywords {
|
||||
const today = i18n.t('date.keyword.today', { returnObjects: true }) as unknown;
|
||||
const tomorrow = i18n.t('date.keyword.tomorrow', { returnObjects: true }) as unknown;
|
||||
const yesterday = i18n.t('date.keyword.yesterday', { returnObjects: true }) as unknown;
|
||||
const weekdays = i18n.t('date.keyword.weekdays', { returnObjects: true }) as unknown;
|
||||
return {
|
||||
today: Array.isArray(today) ? (today as string[]) : ['today'],
|
||||
tomorrow: Array.isArray(tomorrow) ? (tomorrow as string[]) : ['tomorrow'],
|
||||
yesterday: Array.isArray(yesterday) ? (yesterday as string[]) : ['yesterday'],
|
||||
weekdays: Array.isArray(weekdays)
|
||||
? (weekdays as string[][])
|
||||
: [['sun'],['mon'],['tue'],['wed'],['thu'],['fri'],['sat']],
|
||||
};
|
||||
}
|
||||
|
||||
function tryParse(raw: string): Date | null {
|
||||
const parsed = parseDate(raw, prefs, getKeywords());
|
||||
if (!parsed) return null;
|
||||
if (minDate && parsed < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function commit(raw: string, fireCommit: boolean) {
|
||||
if (!raw.trim()) {
|
||||
onChange(undefined);
|
||||
setInvalid(false);
|
||||
return;
|
||||
}
|
||||
const parsed = tryParse(raw);
|
||||
if (parsed) {
|
||||
setInvalid(false);
|
||||
onChange(parsed);
|
||||
if (fireCommit) onCommit?.(parsed);
|
||||
} else {
|
||||
setInvalid(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (flat) {
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id={fieldId}
|
||||
autoFocus={autoFocus}
|
||||
placeholder={placeholder ?? t('timeline.pickDate')}
|
||||
value={text}
|
||||
onChange={(e) => { setText(e.target.value); setInvalid(false); }}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => { setFocused(false); commit(text, false); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commit(text, true);
|
||||
}
|
||||
}}
|
||||
aria-invalid={invalid || !!invalidMessage}
|
||||
aria-describedby={invalidMessage ? errorId : undefined}
|
||||
aria-label={rest['aria-label']}
|
||||
className={cn((invalid || !!invalidMessage) && 'ring-1 ring-destructive')}
|
||||
/>
|
||||
<div className="mt-2 rounded-md border">
|
||||
<CalendarTimeBody
|
||||
value={value}
|
||||
onChange={(d) => {
|
||||
onChange(d);
|
||||
if (d) {
|
||||
setText(formatValue(d, prefs, !!withTime));
|
||||
setInvalid(false);
|
||||
}
|
||||
}}
|
||||
onCommit={onCommit}
|
||||
withTime={!!withTime}
|
||||
minDate={minDate}
|
||||
onAfterPick={() => { inputRef.current?.focus(); }}
|
||||
/>
|
||||
</div>
|
||||
{invalidMessage && (
|
||||
<p id={errorId} className="mt-1 text-xs text-destructive">{invalidMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id={fieldId}
|
||||
autoFocus={autoFocus}
|
||||
placeholder={placeholder ?? t('timeline.pickDate')}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
setInvalid(false);
|
||||
}}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
commit(text, false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commit(text, true);
|
||||
} else if (e.altKey && e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
aria-invalid={invalid || !!invalidMessage}
|
||||
aria-describedby={invalidMessage ? errorId : undefined}
|
||||
aria-label={rest['aria-label']}
|
||||
className={cn('pr-8', (invalid || !!invalidMessage) && 'ring-1 ring-destructive')}
|
||||
/>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full w-8 text-muted-foreground hover:text-foreground"
|
||||
aria-label={t('timeline.pickDate')}
|
||||
tabIndex={-1}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="end">
|
||||
<CalendarTimeBody
|
||||
value={value}
|
||||
onChange={(d) => {
|
||||
onChange(d);
|
||||
if (d) {
|
||||
setText(formatValue(d, prefs, !!withTime));
|
||||
setInvalid(false);
|
||||
}
|
||||
}}
|
||||
onCommit={onCommit}
|
||||
withTime={!!withTime}
|
||||
minDate={minDate}
|
||||
onAfterPick={() => {
|
||||
setOpen(false);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{invalidMessage && (
|
||||
<p id={errorId} className="mt-1 text-xs text-destructive">{invalidMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
342
src/renderer/components/ui/datetime-field.tsx
Normal file
342
src/renderer/components/ui/datetime-field.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import { useFormatPrefs, type FormatPrefs } from '@/lib/date';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface DateTimeFieldProps {
|
||||
value: Date | undefined;
|
||||
onChange: (d: Date | undefined) => void;
|
||||
onCommit?: (d: Date) => void;
|
||||
withTime?: boolean;
|
||||
className?: string;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
type SegKey = 'day' | 'month' | 'year' | 'hour' | 'minute';
|
||||
interface SegDef {
|
||||
key: SegKey;
|
||||
len: number;
|
||||
min: number;
|
||||
max: number;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
const SEGS: Record<SegKey, SegDef> = {
|
||||
day: { key: 'day', len: 2, min: 1, max: 31, placeholder: 'DD' },
|
||||
month: { key: 'month', len: 2, min: 1, max: 12, placeholder: 'MM' },
|
||||
year: { key: 'year', len: 4, min: 1900, max: 2100, placeholder: 'YYYY' },
|
||||
hour: { key: 'hour', len: 2, min: 0, max: 23, placeholder: 'HH' },
|
||||
minute: { key: 'minute', len: 2, min: 0, max: 59, placeholder: 'mm' },
|
||||
};
|
||||
|
||||
type LayoutEntry = { seg: SegKey; sep: string | null };
|
||||
|
||||
function layoutForFormat(fmt: FormatPrefs['dateFormat']): LayoutEntry[] {
|
||||
switch (fmt) {
|
||||
case 'MM/dd/yyyy': return [{ seg: 'month', sep: '/' }, { seg: 'day', sep: '/' }, { seg: 'year', sep: null }];
|
||||
case 'yyyy-MM-dd': return [{ seg: 'year', sep: '-' }, { seg: 'month', sep: '-' }, { seg: 'day', sep: null }];
|
||||
default: return [{ seg: 'day', sep: '/' }, { seg: 'month', sep: '/' }, { seg: 'year', sep: null }];
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(n: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, n));
|
||||
}
|
||||
|
||||
type SegState = Record<SegKey, string>;
|
||||
|
||||
function fromDate(d: Date | undefined): SegState {
|
||||
if (!d) return { day: '', month: '', year: '', hour: '', minute: '' };
|
||||
return {
|
||||
day: String(d.getDate()).padStart(2, '0'),
|
||||
month: String(d.getMonth() + 1).padStart(2, '0'),
|
||||
year: String(d.getFullYear()),
|
||||
hour: String(d.getHours()).padStart(2, '0'),
|
||||
minute: String(d.getMinutes()).padStart(2, '0'),
|
||||
};
|
||||
}
|
||||
|
||||
function toDate(state: SegState, withTime: boolean): Date | undefined {
|
||||
const d = parseInt(state.day, 10);
|
||||
const m = parseInt(state.month, 10);
|
||||
const y = parseInt(state.year, 10);
|
||||
if (!d || !m || !y) return undefined;
|
||||
if (y < 1900 || y > 2100) return undefined;
|
||||
const h = withTime && state.hour !== '' ? parseInt(state.hour, 10) : 0;
|
||||
const mn = withTime && state.minute !== '' ? parseInt(state.minute, 10) : 0;
|
||||
const dt = new Date(y, m - 1, d, h, mn, 0, 0);
|
||||
if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return undefined;
|
||||
return dt;
|
||||
}
|
||||
|
||||
export function DateTimeField({
|
||||
value,
|
||||
onChange,
|
||||
onCommit,
|
||||
withTime = false,
|
||||
className,
|
||||
...rest
|
||||
}: DateTimeFieldProps) {
|
||||
const prefs = useFormatPrefs();
|
||||
const dateLayout = useMemo(() => layoutForFormat(prefs.dateFormat), [prefs.dateFormat]);
|
||||
const order: SegKey[] = useMemo(() => {
|
||||
const base = dateLayout.map((l) => l.seg);
|
||||
return withTime ? [...base, 'hour', 'minute'] : base;
|
||||
}, [dateLayout, withTime]);
|
||||
|
||||
const [seg, setSeg] = useState<SegState>(() => fromDate(value));
|
||||
const refs = useRef<Record<SegKey, HTMLSpanElement | null>>({
|
||||
day: null, month: null, year: null, hour: null, minute: null,
|
||||
});
|
||||
// Stable per-segment ref setters (avoid new-function-per-render).
|
||||
const refSetters = useRef<Record<SegKey, (el: HTMLSpanElement | null) => void>>({
|
||||
day: (el) => { refs.current.day = el; },
|
||||
month: (el) => { refs.current.month = el; },
|
||||
year: (el) => { refs.current.year = el; },
|
||||
hour: (el) => { refs.current.hour = el; },
|
||||
minute: (el) => { refs.current.minute = el; },
|
||||
});
|
||||
|
||||
function focusSeg(key: SegKey) {
|
||||
const el = refs.current[key];
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
|
||||
// Note: typing updates LOCAL state only. We deliberately don't call
|
||||
// onChange on every keystroke — otherwise the parent re-renders on each
|
||||
// keypress, which re-renders the (heavy) Calendar grid and the rest of
|
||||
// TaskFormDialog. onChange only fires on commit (Enter) or calendar pick.
|
||||
|
||||
// Stable across renders: uses functional setSeg, refs, and order via ref.
|
||||
const orderRef = useRef(order);
|
||||
orderRef.current = order;
|
||||
const withTimeRef = useRef(withTime);
|
||||
withTimeRef.current = withTime;
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
const onCommitRef = useRef(onCommit);
|
||||
onCommitRef.current = onCommit;
|
||||
|
||||
const onSegKeyDown = useCallback((e: ReactKeyboardEvent<HTMLSpanElement>, key: SegKey) => {
|
||||
const def = SEGS[key];
|
||||
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
const idx = orderRef.current.indexOf(key);
|
||||
const nxt = orderRef.current[idx + 1];
|
||||
if (nxt) focusSeg(nxt);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
const idx = orderRef.current.indexOf(key);
|
||||
const prv = orderRef.current[idx - 1];
|
||||
if (prv) focusSeg(prv);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSeg((prev) => {
|
||||
const cur = prev[key];
|
||||
const delta = e.key === 'ArrowUp' ? 1 : -1;
|
||||
const base = cur === '' ? (key === 'hour' || key === 'minute' ? 0 : def.min) : parseInt(cur, 10);
|
||||
let n = base + delta;
|
||||
if (n < def.min) n = def.max;
|
||||
if (n > def.max) n = def.min;
|
||||
return { ...prev, [key]: String(n).padStart(def.len, '0') };
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
e.preventDefault();
|
||||
setSeg((prev) => {
|
||||
if (prev[key] === '') {
|
||||
const idx = orderRef.current.indexOf(key);
|
||||
const prv = orderRef.current[idx - 1];
|
||||
if (prv) focusSeg(prv);
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, [key]: '' };
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (/^[0-9]$/.test(e.key)) {
|
||||
e.preventDefault();
|
||||
let advance = false;
|
||||
setSeg((prev) => {
|
||||
const cur = prev[key];
|
||||
const incoming = cur.length >= def.len ? e.key : cur + e.key;
|
||||
const numeric = parseInt(incoming, 10);
|
||||
const final = numeric > def.max ? e.key : incoming;
|
||||
const padded = final.padStart(Math.min(final.length, def.len), '0');
|
||||
if (padded.length >= def.len || parseInt(padded, 10) * 10 > def.max) {
|
||||
advance = true;
|
||||
}
|
||||
return { ...prev, [key]: padded };
|
||||
});
|
||||
if (advance) {
|
||||
const idx = orderRef.current.indexOf(key);
|
||||
const nxt = orderRef.current[idx + 1];
|
||||
if (nxt) focusSeg(nxt);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
// Read current seg via functional updater; commit then propagate.
|
||||
setSeg((prev) => {
|
||||
const today = new Date();
|
||||
const wt = withTimeRef.current;
|
||||
const filled: SegState = {
|
||||
day: prev.day || String(today.getDate()).padStart(2, '0'),
|
||||
month: prev.month || String(today.getMonth() + 1).padStart(2, '0'),
|
||||
year: prev.year || String(today.getFullYear()),
|
||||
hour: wt ? (prev.hour || '00') : '00',
|
||||
minute: wt ? (prev.minute || '00') : '00',
|
||||
};
|
||||
const monthN = clamp(parseInt(filled.month, 10), SEGS.month.min, SEGS.month.max);
|
||||
const yearN = clamp(parseInt(filled.year, 10), SEGS.year.min, SEGS.year.max);
|
||||
const hourN = clamp(parseInt(filled.hour, 10), SEGS.hour.min, SEGS.hour.max);
|
||||
const minuteN = clamp(parseInt(filled.minute, 10), SEGS.minute.min, SEGS.minute.max);
|
||||
const lastDayOfMonth = new Date(yearN, monthN, 0).getDate();
|
||||
const dayN = clamp(parseInt(filled.day, 10), SEGS.day.min, lastDayOfMonth);
|
||||
const dt = new Date(yearN, monthN - 1, dayN, hourN, minuteN, 0, 0);
|
||||
onChangeRef.current(dt);
|
||||
onCommitRef.current?.(dt);
|
||||
return fromDate(dt);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (e.key === '/' || e.key === '-' || e.key === ':' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
const idx = orderRef.current.indexOf(key);
|
||||
const nxt = orderRef.current[idx + 1];
|
||||
if (nxt) focusSeg(nxt);
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onCalendarSelect = useCallback((d: Date | undefined) => {
|
||||
if (!d) return;
|
||||
setSeg((prev) => {
|
||||
const next: SegState = {
|
||||
...prev,
|
||||
day: String(d.getDate()).padStart(2, '0'),
|
||||
month: String(d.getMonth() + 1).padStart(2, '0'),
|
||||
year: String(d.getFullYear()),
|
||||
};
|
||||
const dt = toDate(next, withTime);
|
||||
if (dt) onChange(dt);
|
||||
return next;
|
||||
});
|
||||
}, [withTime, onChange]);
|
||||
|
||||
const selectedDate = toDate(seg, withTime);
|
||||
const selectedMs = selectedDate ? selectedDate.getTime() : null;
|
||||
const calendarEl = useMemo(
|
||||
() => (
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={onCalendarSelect}
|
||||
/>
|
||||
),
|
||||
// selectedMs primary key; selectedDate/onCalendarSelect captured for closure.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[selectedMs, onCalendarSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-3', className)} aria-label={rest['aria-label']}>
|
||||
<div
|
||||
className="inline-flex items-center gap-0.5 rounded-md border border-input bg-background px-3 py-2 text-sm font-mono focus-within:ring-2 focus-within:ring-ring/30 focus-within:border-ring"
|
||||
role="group"
|
||||
>
|
||||
{dateLayout.map(({ seg: sk, sep }) => (
|
||||
<SegmentSpan
|
||||
key={sk}
|
||||
segKey={sk}
|
||||
value={seg[sk]}
|
||||
onKeyDown={onSegKeyDown}
|
||||
registerRef={refSetters.current[sk]}
|
||||
sep={sep}
|
||||
/>
|
||||
))}
|
||||
{withTime && (
|
||||
<>
|
||||
<span className="px-1.5 text-muted-foreground/60 select-none"> </span>
|
||||
<SegmentSpan
|
||||
segKey="hour"
|
||||
value={seg.hour}
|
||||
onKeyDown={onSegKeyDown}
|
||||
registerRef={refSetters.current.hour}
|
||||
sep=":"
|
||||
/>
|
||||
<SegmentSpan
|
||||
segKey="minute"
|
||||
value={seg.minute}
|
||||
onKeyDown={onSegKeyDown}
|
||||
registerRef={refSetters.current.minute}
|
||||
sep={null}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-md border">{calendarEl}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SegmentSpan = memo(function SegmentSpan({
|
||||
segKey,
|
||||
value,
|
||||
onKeyDown,
|
||||
registerRef,
|
||||
sep,
|
||||
}: {
|
||||
segKey: SegKey;
|
||||
value: string;
|
||||
onKeyDown: (e: ReactKeyboardEvent<HTMLSpanElement>, key: SegKey) => void;
|
||||
registerRef: (el: HTMLSpanElement | null) => void;
|
||||
sep: string | null;
|
||||
}) {
|
||||
const def = SEGS[segKey];
|
||||
const isEmpty = value === '';
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={registerRef}
|
||||
tabIndex={0}
|
||||
role="spinbutton"
|
||||
aria-label={def.placeholder}
|
||||
aria-valuemin={def.min}
|
||||
aria-valuemax={def.max}
|
||||
aria-valuenow={isEmpty ? undefined : clamp(parseInt(value, 10), def.min, def.max)}
|
||||
onKeyDown={(e) => onKeyDown(e, segKey)}
|
||||
onFocus={(e) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(e.currentTarget);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}}
|
||||
className={cn(
|
||||
'inline-block text-center outline-none rounded px-0.5 cursor-text tabular-nums',
|
||||
def.len === 4 ? 'min-w-[3.5ch]' : 'min-w-[1.8ch]',
|
||||
isEmpty && 'text-muted-foreground/60',
|
||||
'focus:bg-accent',
|
||||
)}
|
||||
>
|
||||
{isEmpty ? def.placeholder : value}
|
||||
</span>
|
||||
{sep && <span className="text-muted-foreground/70 select-none px-0.5">{sep}</span>}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -59,7 +59,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] sm:max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
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 };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user