Compare commits

...

17 Commits

Author SHA1 Message Date
Roberto
b0c415f90f feat(adiuvAI): pre-flight quota check + error toasts for folder integration
Before starting an index session, scanFolder is called to count
indexable files, then BackendClient.checkFolderQuota POSTs to
/api/v1/billing/quota/check.  A 402 response becomes a TRPCError
FORBIDDEN with a QUOTA:<reason>:<message> payload.  FilesSection
catches that payload and shows a localised sonner toast via
projects.folder.errors.tooBig or monthlyExhausted.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:00:36 +02:00
26 changed files with 2391 additions and 7 deletions

View File

@@ -163,6 +163,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
// ---------------------------------------------------------------------------
@@ -183,6 +193,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 +222,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() {}
@@ -564,6 +583,64 @@ 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));
}
// -------------------------------------------------------------------------
// HTTP utilities
// -------------------------------------------------------------------------
@@ -678,6 +755,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);
}
// -------------------------------------------------------------------------
/**
@@ -861,6 +1004,32 @@ 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;
}
}
});
@@ -895,6 +1064,8 @@ export class BackendClient {
}
this.journeyListeners.clear();
}
// Index session listeners are fire-and-forget; just drop them on disconnect.
this.indexListeners.clear();
if (this.shouldReconnect) {
this.scheduleReconnect();

View File

@@ -15,7 +15,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { eq, and, or, like, isNull, asc, desc, gte, lte, sql, SQL } from 'drizzle-orm';
import { 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;
@@ -187,6 +188,12 @@ 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();
default:
throw new ExecutorError(`Unknown action: "${action as string}"`);
}
@@ -436,4 +443,129 @@ 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 } = (payload.data ?? {}) as {
projectId: string;
relativePath: string;
};
// Re-check guards even though backend tool also guards
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: '' };
const abs = path.join(proj.folderPath, relativePath);
// Confine to folderPath
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') };
}
if (stat.size > MAX_READ_SIZE_BYTES) {
const buf = Buffer.alloc(MAX_READ_SIZE_BYTES);
const fd = await fs.promises.open(abs, 'r');
try {
await fd.read(buf, 0, MAX_READ_SIZE_BYTES, 0);
} finally {
await fd.close();
}
return { content: buf.toString('utf8') + '\n[…truncated]' };
}
return { content: await fs.promises.readFile(abs, 'utf-8') };
} catch {
return { content: '' };
}
}
private handleListProjectsWithFolderManifests(): Record<string, unknown> {
const projs = getDb()
.select()
.from(projects)
.where(sql`${projects.folderPath} IS NOT NULL`)
.all();
const out: Array<unknown> = [];
for (const p of projs) {
const files = getDb()
.select({
relPath: projectFolderFiles.relativePath,
kind: projectFolderFiles.kind,
summary: projectFolderFiles.summary,
mtimeMs: projectFolderFiles.mtimeMs,
})
.from(projectFolderFiles)
.where(eq(projectFolderFiles.projectId, p.id))
.all();
out.push({
projectId: p.id,
projectName: p.name,
folderPath: p.folderPath,
lastScannedAt: p.folderLastScannedAt,
files,
});
}
return { projects: out };
}
}

View File

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

View File

@@ -0,0 +1,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": {}
}
}

View File

@@ -36,6 +36,13 @@
"when": 1778238659431,
"tag": "0004_right_alex_power",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1778579196669,
"tag": "0005_slim_baron_strucker",
"breakpoints": true
}
]
}

View File

@@ -16,6 +16,12 @@ export const projects = sqliteTable('projects', {
status: text('status', { enum: ['active', 'archived'] }).notNull().default('active'),
aiSummary: text('ai_summary'),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
folderPath: text('folder_path'),
folderLastScannedAt: integer('folder_last_scanned_at', { mode: 'number' }),
folderLastScanStatus: text('folder_last_scan_status', {
enum: ['idle', 'scanning', 'error'],
}).default('idle'),
folderTotalFiles: integer('folder_total_files', { mode: 'number' }).notNull().default(0),
});
export const tasks = sqliteTable('tasks', {
@@ -64,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(),

View File

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

View File

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

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

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

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

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

View File

@@ -10,6 +10,7 @@ import { getStore } from './store';
import { startBriefScheduler, stopBriefScheduler } from './ai/orchestrator';
import { startAgentScheduler, stopAgentScheduler } from './agents/agent-scheduler';
import { backfillNoteSummaries } from './db/notes-backfill';
import { runDailyRescan } from './files/daily-rescan';
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (started) {
@@ -134,6 +135,8 @@ app.on('ready', () => {
startBriefScheduler();
startAgentScheduler();
// 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

View File

@@ -17,6 +17,7 @@ import { orchestrate, orchestrateFloating, orchestrateTaskBriefResearch, dailyBr
import { getAuthManager, AuthError } from '../auth/auth-manager';
import { detectFormatPrefs, detectLanguage } from '../auth/locale-defaults';
import type { TRPCContext } from '../ipc';
import { projectFoldersRouter } from './projectFolders';
const t = initTRPC.context<TRPCContext>().create();
@@ -1854,6 +1855,7 @@ export const appRouter = router({
auth: authRouter,
agent: agentRouter,
memory: memoryRouter,
projectFolders: projectFoldersRouter,
});
export type AppRouter = typeof appRouter;

View File

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

View File

@@ -26,6 +26,8 @@ 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';
type ProjectDetailProps = {
projectId: string;
@@ -46,11 +48,13 @@ 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);
@@ -58,6 +62,11 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
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,
@@ -457,6 +466,38 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
{subtitle}
</span>
</h1>
<div
className={cn(
'overflow-hidden transition-all duration-200 ease-out',
compact ? 'max-h-0 mt-0 opacity-0' : 'max-h-8 mt-2 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>
@@ -614,6 +655,17 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
</section>
</div>
{/* Files section */}
<div ref={filesRef} data-section="files" className="mx-auto max-w-6xl px-8 py-10 border-t border-border/40">
<FilesSection
projectId={project.id}
folderPath={project.folderPath ?? null}
totalFiles={project.folderTotalFiles ?? 0}
lastScannedAt={project.folderLastScannedAt ?? null}
scanStatus={(project.folderLastScanStatus ?? 'idle') as 'idle' | 'scanning' | 'error'}
/>
</div>
</div>
);
}

View File

@@ -3,7 +3,7 @@ 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 {
@@ -83,6 +83,7 @@ export function ProjectTabBar({ sectionRefs, scrollRef, heroRef, initialTab }: P
timeline: t('projects.projectTimeline'),
tasks: t('projects.tasks'),
notes: t('projects.notes'),
files: t('projects.folder.title'),
};
return (

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,68 @@
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="border border-border rounded-xl p-4 flex items-start gap-3 bg-background">
<div className="h-10 w-10 rounded-lg bg-[#fbc881]/30 grid place-items-center shrink-0">
<Folder className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{t('projects.folder.title')}</div>
<div className="font-mono text-xs text-muted-foreground truncate">{folderPath}</div>
<div className="text-xs text-muted-foreground mt-1">
{t('projects.folder.filesCount', { count: totalFiles })}
{lastScannedAt && (
<>
{' · '}
{t('projects.folder.lastScanned', {
relative: formatDistanceToNow(new Date(lastScannedAt), { addSuffix: true }),
})}
</>
)}
</div>
</div>
<div className="flex gap-2 shrink-0">
<Button
variant="outline"
size="sm"
disabled={scanStatus === 'scanning' || startScan.isPending}
onClick={() => startScan.mutate({ projectId })}
>
{t('projects.folder.rescan')}
</Button>
<Button variant="ghost" size="sm" onClick={onUnlinkRequested}>
{t('projects.folder.unlink')}
</Button>
</div>
</div>
);
}

View File

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

View File

@@ -373,7 +373,30 @@
"untitledNote": "Unbenannte Notiz",
"noNotesYet": "Noch keine Notizen.",
"noNotesYetDescription": "Klicke auf \"+ Hinzufügen\", um deine erste Notiz zu erstellen.",
"overview": "Übersicht"
"overview": "Übersicht",
"folder": {
"title": "Dateien",
"linkCta": "Ordner verknüpfen",
"browse": "Durchsuchen",
"rescan": "Neu scannen",
"unlink": "Verknüpfung aufheben",
"filesCount_one": "{{count}} Datei",
"filesCount_other": "{{count}} Dateien",
"lastScanned": "zuletzt gescannt {{relative}}",
"scanning": "Indizierung {{processed}}/{{total}}",
"scanFailed": "Scan fehlgeschlagen",
"empty": {
"title": "Einen Projektordner verknüpfen",
"description": "Verbinden Sie einen lokalen Ordner, damit KI-Agenten dessen Dateien lesen können, wenn sie Fragen zu diesem Projekt beantworten.",
"cta": "Ordner auswählen…"
},
"errors": {
"tooBig": "Ordner zu groß für den {{tier}}-Plan — max. {{count}} Dateien",
"monthlyExhausted": "Monatliches Token-Budget erschöpft (zurückgesetzt am {{date}})",
"notFound": "Ordner nicht gefunden: {{path}}"
},
"webOnlyTooltip": "Ordnerverknüpfung ist in der Desktop-App verfügbar"
}
},
"agents": {
"title": "Agenten",

View File

@@ -373,7 +373,30 @@
"untitledNote": "Untitled Note",
"noNotesYet": "No notes yet.",
"noNotesYetDescription": "Click \"+ Add\" to create your first note.",
"overview": "Overview"
"overview": "Overview",
"folder": {
"title": "Files",
"linkCta": "Link folder",
"browse": "Browse",
"rescan": "Rescan",
"unlink": "Unlink",
"filesCount_one": "{{count}} file",
"filesCount_other": "{{count}} files",
"lastScanned": "last scanned {{relative}}",
"scanning": "indexing {{processed}}/{{total}}",
"scanFailed": "Scan failed",
"empty": {
"title": "Link a project folder",
"description": "Connect a local folder so AI agents can read its files when answering questions about this project.",
"cta": "Choose folder…"
},
"errors": {
"tooBig": "Folder too big for {{tier}} plan — max {{count}} files",
"monthlyExhausted": "Monthly token budget exhausted (resets {{date}})",
"notFound": "Folder not found: {{path}}"
},
"webOnlyTooltip": "Folder linking available in desktop app"
}
},
"agents": {
"title": "Agents",

View File

@@ -373,7 +373,30 @@
"untitledNote": "Nota sin título",
"noNotesYet": "No hay notas.",
"noNotesYetDescription": "Haz clic en \"+ Añadir\" para crear tu primera nota.",
"overview": "Resumen"
"overview": "Resumen",
"folder": {
"title": "Archivos",
"linkCta": "Vincular carpeta",
"browse": "Explorar",
"rescan": "Reescanear",
"unlink": "Desvincular",
"filesCount_one": "{{count}} archivo",
"filesCount_other": "{{count}} archivos",
"lastScanned": "último escaneo {{relative}}",
"scanning": "indexando {{processed}}/{{total}}",
"scanFailed": "Error de escaneo",
"empty": {
"title": "Vincula una carpeta al proyecto",
"description": "Conecta una carpeta local para que los agentes de IA puedan leer sus archivos al responder preguntas sobre este proyecto.",
"cta": "Elegir carpeta…"
},
"errors": {
"tooBig": "Carpeta demasiado grande para el plan {{tier}} — máx. {{count}} archivos",
"monthlyExhausted": "Presupuesto mensual de tokens agotado (se renueva el {{date}})",
"notFound": "Carpeta no encontrada: {{path}}"
},
"webOnlyTooltip": "La vinculación de carpetas está disponible en la app de escritorio"
}
},
"agents": {
"title": "Agentes",

View File

@@ -373,7 +373,30 @@
"untitledNote": "Note sans titre",
"noNotesYet": "Aucune note.",
"noNotesYetDescription": "Cliquez sur \"+ Ajouter\" pour créer votre première note.",
"overview": "Vue d'ensemble"
"overview": "Vue d'ensemble",
"folder": {
"title": "Fichiers",
"linkCta": "Lier un dossier",
"browse": "Parcourir",
"rescan": "Rescanner",
"unlink": "Dissocier",
"filesCount_one": "{{count}} fichier",
"filesCount_other": "{{count}} fichiers",
"lastScanned": "dernier scan {{relative}}",
"scanning": "indexation {{processed}}/{{total}}",
"scanFailed": "Échec du scan",
"empty": {
"title": "Lier un dossier au projet",
"description": "Connectez un dossier local pour que les agents IA puissent lire ses fichiers lorsqu'ils répondent aux questions sur ce projet.",
"cta": "Choisir un dossier…"
},
"errors": {
"tooBig": "Dossier trop grand pour le plan {{tier}} — max {{count}} fichiers",
"monthlyExhausted": "Budget mensuel de tokens épuisé (réinitialisé le {{date}})",
"notFound": "Dossier introuvable : {{path}}"
},
"webOnlyTooltip": "La liaison de dossiers est disponible dans l'application bureau"
}
},
"agents": {
"title": "Agents",

View File

@@ -373,7 +373,30 @@
"untitledNote": "Nota senza titolo",
"noNotesYet": "Nessuna nota.",
"noNotesYetDescription": "Clicca \"+ Aggiungi\" per creare la tua prima nota.",
"overview": "Panoramica"
"overview": "Panoramica",
"folder": {
"title": "File",
"linkCta": "Collega cartella",
"browse": "Sfoglia",
"rescan": "Riscansiona",
"unlink": "Scollega",
"filesCount_one": "{{count}} file",
"filesCount_other": "{{count}} file",
"lastScanned": "ultima scansione {{relative}}",
"scanning": "indicizzazione {{processed}}/{{total}}",
"scanFailed": "Scansione fallita",
"empty": {
"title": "Collega una cartella al progetto",
"description": "Connetti una cartella locale in modo che gli agenti AI possano leggerne i file quando rispondono alle domande su questo progetto.",
"cta": "Scegli cartella…"
},
"errors": {
"tooBig": "Cartella troppo grande per il piano {{tier}} — max {{count}} file",
"monthlyExhausted": "Budget mensile token esaurito (si rinnova il {{date}})",
"notFound": "Cartella non trovata: {{path}}"
},
"webOnlyTooltip": "Il collegamento cartelle è disponibile nell'app desktop"
}
},
"agents": {
"title": "Agenti",

View File

@@ -278,6 +278,31 @@ export const WsRunCompleteSchema = z.object({
});
export type WsRunComplete = z.infer<typeof WsRunCompleteSchema>;
export const WsIndexFileResultSchema = z.object({
type: z.literal('index_file_result'),
sessionId: z.string(),
relPath: z.string(),
summary: z.string().nullable().optional(),
tokensUsed: z.number().int().nonnegative().default(0),
error: z.string().optional(),
});
export type WsIndexFileResult = z.infer<typeof WsIndexFileResultSchema>;
export const WsIndexSessionProgressSchema = z.object({
type: z.literal('index_session_progress'),
sessionId: z.string(),
processed: z.number().int().nonnegative(),
total: z.number().int().nonnegative(),
});
export type WsIndexSessionProgress = z.infer<typeof WsIndexSessionProgressSchema>;
export const WsIndexSessionDoneSchema = z.object({
type: z.literal('index_session_done'),
sessionId: z.string(),
status: z.enum(['completed', 'cancelled', 'quota_exceeded', 'error']),
});
export type WsIndexSessionDone = z.infer<typeof WsIndexSessionDoneSchema>;
export const WsServerFrameSchema = z.discriminatedUnion('type', [
WsToolCallSchema,
WsPingSchema,
@@ -287,6 +312,9 @@ export const WsServerFrameSchema = z.discriminatedUnion('type', [
WsFloatingDomainSchema,
WsJourneyReplySchema,
WsRunCompleteSchema,
WsIndexFileResultSchema,
WsIndexSessionProgressSchema,
WsIndexSessionDoneSchema,
]);
export type WsServerFrame = z.infer<typeof WsServerFrameSchema>;