Compare commits
31 Commits
81fe6d29e2
...
c1b1b289c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -98,3 +98,9 @@ dist-web/
|
||||
.vscode/
|
||||
.agents/
|
||||
src/renderer/routeTree.gen.ts
|
||||
|
||||
# Local dev SQLite (used by drizzle-kit push for schema verification)
|
||||
dev.db
|
||||
dev.db-journal
|
||||
dev.db-shm
|
||||
dev.db-wal
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/main/db/schema.ts',
|
||||
out: './src/main/db/migrations',
|
||||
dialect: 'sqlite',
|
||||
dbCredentials: {
|
||||
url: `file:${path.resolve('./dev.db')}`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* All AI intelligence lives on the backend. The Electron process:
|
||||
* 1. Checks connectivity + auth status
|
||||
* 2. Delegates to BackendClient.sendHomeRequest() / sendFloatingRequest()
|
||||
* 2. Delegates to BackendClient.sendHomeRequest() / sendContextualRequest()
|
||||
* which handle the WS lifecycle, tool-call ↔ DrizzleExecutor round-trips,
|
||||
* and v3 stream event dispatch.
|
||||
* 3. Forwards v3 typed stream frames to the renderer via IPC.
|
||||
@@ -13,8 +13,6 @@ import { BrowserWindow } from 'electron';
|
||||
import { getBackendClient, OfflineError, AuthExpiredError } from '../api/backend-client';
|
||||
import { getAuthManager } from '../auth/auth-manager';
|
||||
import { getStore } from '../store';
|
||||
import type { WsFloatingRequest } from '../../shared/api-types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -37,14 +35,12 @@ interface OrchestrateInput {
|
||||
sender?: Electron.WebContents;
|
||||
}
|
||||
|
||||
interface OrchestrateFloatingInput {
|
||||
interface OrchestrateContextualInput {
|
||||
message: string;
|
||||
requestId?: string;
|
||||
sessionId?: string;
|
||||
scope: WsFloatingRequest['scope'];
|
||||
conversationHistory?: WsFloatingRequest['conversationHistory'];
|
||||
briefMode?: boolean;
|
||||
briefingContext?: string;
|
||||
scope: unknown;
|
||||
conversationHistory?: Array<{ role: string; content: string }>;
|
||||
sender?: Electron.WebContents;
|
||||
}
|
||||
|
||||
@@ -127,22 +123,21 @@ export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateR
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orchestrate Floating — Floating chat (public entry point)
|
||||
// Orchestrate Contextual — Contextual sidebar chat (public entry point)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function orchestrateFloating(input: OrchestrateFloatingInput): Promise<OrchestrateResult> {
|
||||
const { message, requestId, sessionId, scope, conversationHistory, briefMode, briefingContext, sender } = input;
|
||||
export async function orchestrateContextual(input: OrchestrateContextualInput): Promise<OrchestrateResult> {
|
||||
const { message, requestId, sessionId, scope, conversationHistory, sender } = input;
|
||||
|
||||
const check = await checkConnectivity();
|
||||
if (!check.ok) return { response: '', error: check.error };
|
||||
|
||||
try {
|
||||
const client = getBackendClient();
|
||||
const { requestId: activeRequestId, promise } = client.sendFloatingRequest(message, scope, conversationHistory, requestId, sessionId, briefMode, briefingContext, {
|
||||
const { requestId: activeRequestId, promise } = client.sendContextualRequest(message, scope, conversationHistory, requestId, sessionId, {
|
||||
onStart: () => sendFrame(sender, { type: 'stream_start', requestId: activeRequestId }),
|
||||
onText: (chunk) => sendFrame(sender, { type: 'stream_text', requestId: activeRequestId, chunk }),
|
||||
onEnd: (mutations) => sendFrame(sender, { type: 'stream_end', requestId: activeRequestId, mutations: mutations as unknown[] | undefined }),
|
||||
onDomain: (domain) => sendFrame(sender, { type: 'floating_domain', requestId: activeRequestId, domain }),
|
||||
onError: () => sendFrame(sender, { type: 'stream_end', requestId: activeRequestId }),
|
||||
});
|
||||
|
||||
|
||||
@@ -29,8 +29,6 @@ import {
|
||||
} from '../../shared/api-types';
|
||||
import type {
|
||||
WsToolResult,
|
||||
WsFloatingRequest,
|
||||
WsFloatingDomain,
|
||||
} from '../../shared/api-types';
|
||||
import { DrizzleExecutor } from './drizzle-executor';
|
||||
import { getDb } from '../db';
|
||||
@@ -181,7 +179,6 @@ interface StreamListener {
|
||||
onStart: () => void;
|
||||
onText: (chunk: string) => void;
|
||||
onEnd: (mutations?: unknown) => void;
|
||||
onDomain: (domain: WsFloatingDomain['domain']) => void;
|
||||
onError: (err: Error) => void;
|
||||
resolve: () => void;
|
||||
reject: (err: Error) => void;
|
||||
@@ -292,7 +289,6 @@ export class BackendClient {
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
resolve();
|
||||
},
|
||||
onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
|
||||
onError: (err) => {
|
||||
callbacks?.onError?.(err);
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
@@ -352,7 +348,6 @@ export class BackendClient {
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
resolve();
|
||||
},
|
||||
onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
|
||||
onError: (err) => {
|
||||
callbacks?.onError?.(err);
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
@@ -391,72 +386,6 @@ export class BackendClient {
|
||||
return { requestId: activeRequestId, promise };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a floating chat request over the persistent device WS.
|
||||
* Same listener pattern as `sendHomeRequest`.
|
||||
*/
|
||||
sendFloatingRequest(
|
||||
message: string,
|
||||
scope: WsFloatingRequest['scope'],
|
||||
conversationHistory?: WsFloatingRequest['conversationHistory'],
|
||||
requestId?: string,
|
||||
sessionId?: string,
|
||||
briefMode?: boolean,
|
||||
briefingContext?: string,
|
||||
callbacks?: Partial<Omit<StreamListener, 'resolve' | 'reject'>>,
|
||||
): { requestId: string; promise: Promise<void> } {
|
||||
const activeRequestId = requestId ?? crypto.randomUUID();
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
this.streamListeners.set(activeRequestId, {
|
||||
onStart: callbacks?.onStart ?? ((): void => { /* no-op */ }),
|
||||
onText: callbacks?.onText ?? ((): void => { /* no-op */ }),
|
||||
onEnd: (mutations) => {
|
||||
callbacks?.onEnd?.(mutations);
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
resolve();
|
||||
},
|
||||
onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
|
||||
onError: (err) => {
|
||||
callbacks?.onError?.(err);
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
reject(err);
|
||||
},
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
|
||||
const ws = this.persistentWs;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
reject(new OfflineError('Persistent WS not connected'));
|
||||
return;
|
||||
}
|
||||
|
||||
const rawPrefs = getFormatPrefs() ?? detectFormatPrefs();
|
||||
const floatingPayload = toSnakeCase({
|
||||
type: 'floating_request',
|
||||
requestId: activeRequestId,
|
||||
sessionId,
|
||||
message,
|
||||
scope,
|
||||
conversationHistory,
|
||||
briefMode: briefMode ?? false,
|
||||
briefingContext: briefingContext ?? null,
|
||||
formatPrefs: {
|
||||
timezone: rawPrefs.timezone,
|
||||
dateFormat: rawPrefs.dateFormat,
|
||||
timeFormat: rawPrefs.timeFormat,
|
||||
locale: app.getLocale(),
|
||||
nowIso: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
logWsSend(floatingPayload);
|
||||
ws.send(JSON.stringify(floatingPayload));
|
||||
});
|
||||
|
||||
return { requestId: activeRequestId, promise };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a task brief research request over the persistent device WS.
|
||||
* Backend runs a deep-research Stage 1 agent and streams the briefing.
|
||||
@@ -477,7 +406,6 @@ export class BackendClient {
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
resolve();
|
||||
},
|
||||
onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
|
||||
onError: (err) => {
|
||||
callbacks?.onError?.(err);
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
@@ -641,6 +569,92 @@ export class BackendClient {
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Contextual chat — send via persistent device WS
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Send a contextual chat request over the persistent device WS.
|
||||
* Same listener pattern as `sendHomeRequest` but uses the
|
||||
* `contextual_request` frame type.
|
||||
*/
|
||||
sendContextualRequest(
|
||||
message: string,
|
||||
scope: unknown,
|
||||
conversationHistory?: Array<{ role: string; content: string }>,
|
||||
requestId?: string,
|
||||
sessionId?: string,
|
||||
callbacks?: Partial<Omit<StreamListener, 'resolve' | 'reject'>>,
|
||||
): { requestId: string; promise: Promise<void> } {
|
||||
const activeRequestId = requestId ?? crypto.randomUUID();
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
this.streamListeners.set(activeRequestId, {
|
||||
onStart: callbacks?.onStart ?? ((): void => { /* no-op */ }),
|
||||
onText: callbacks?.onText ?? ((): void => { /* no-op */ }),
|
||||
onEnd: (mutations) => {
|
||||
callbacks?.onEnd?.(mutations);
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
resolve();
|
||||
},
|
||||
onError: (err) => {
|
||||
callbacks?.onError?.(err);
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
reject(err);
|
||||
},
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
|
||||
const ws = this.persistentWs;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
this.streamListeners.delete(activeRequestId);
|
||||
reject(new OfflineError('Persistent WS not connected'));
|
||||
return;
|
||||
}
|
||||
|
||||
const rawPrefs = getFormatPrefs() ?? detectFormatPrefs();
|
||||
const contextualPayload = toSnakeCase({
|
||||
type: 'contextual_request',
|
||||
requestId: activeRequestId,
|
||||
sessionId,
|
||||
message,
|
||||
scope,
|
||||
conversationHistory,
|
||||
formatPrefs: {
|
||||
timezone: rawPrefs.timezone,
|
||||
dateFormat: rawPrefs.dateFormat,
|
||||
timeFormat: rawPrefs.timeFormat,
|
||||
locale: app.getLocale(),
|
||||
nowIso: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
logWsSend(contextualPayload);
|
||||
ws.send(JSON.stringify(contextualPayload));
|
||||
});
|
||||
|
||||
return { requestId: activeRequestId, promise };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a contextual scope update over the persistent device WS.
|
||||
* Fire-and-forget — backend responds with `contextual_scope_ack` which
|
||||
* we do not need to handle on the Electron side.
|
||||
*/
|
||||
sendContextualScopeUpdate(args: { sessionId: string; scope: unknown }): void {
|
||||
const ws = this.persistentWs;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn('[DeviceWS] sendContextualScopeUpdate: WS not connected — dropping scope update.');
|
||||
return;
|
||||
}
|
||||
const payload = toSnakeCase({
|
||||
type: 'contextual_scope_update',
|
||||
sessionId: args.sessionId,
|
||||
scope: args.scope,
|
||||
});
|
||||
logWsSend(payload);
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// HTTP utilities
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -970,12 +984,6 @@ 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 () => {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { eq, and, or, like, isNull, asc, desc, gte, lte, sql, SQL } from 'drizzle-orm';
|
||||
import { eq, and, or, like, isNull, asc, desc, gte, lte, inArray, sql, SQL } from 'drizzle-orm';
|
||||
import { getDb } from '../db';
|
||||
import { tasks, projects, clients, timelineEvents, notes, noteEdits, taskComments, projectFolderFiles } from '../db/schema';
|
||||
import type { WsToolCall } from '../../shared/api-types';
|
||||
@@ -100,6 +100,20 @@ function buildConditions(
|
||||
|
||||
if (value === null) {
|
||||
conditions.push(isNull(col as Parameters<typeof isNull>[0]));
|
||||
} else if (typeof value === 'string' && value.includes(',')) {
|
||||
const parts = value.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
if (parts.length > 1) {
|
||||
conditions.push(inArray(col as Parameters<typeof inArray>[0], parts));
|
||||
} else if (parts.length === 1) {
|
||||
conditions.push(eq(col as Parameters<typeof eq>[0], parts[0]));
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
const parts = value.map((v) => String(v)).filter(Boolean);
|
||||
if (parts.length > 1) {
|
||||
conditions.push(inArray(col as Parameters<typeof inArray>[0], parts));
|
||||
} else if (parts.length === 1) {
|
||||
conditions.push(eq(col as Parameters<typeof eq>[0], parts[0]));
|
||||
}
|
||||
} else {
|
||||
conditions.push(eq(col as Parameters<typeof eq>[0], value as Parameters<typeof eq>[1]));
|
||||
}
|
||||
@@ -194,6 +208,8 @@ export class DrizzleExecutor {
|
||||
return this.handleReadProjectFolderFile(payload);
|
||||
case 'list_projects_with_folder_manifests':
|
||||
return this.handleListProjectsWithFolderManifests();
|
||||
case 'get_page_details':
|
||||
return this.handleGetPageDetails(payload);
|
||||
default:
|
||||
throw new ExecutorError(`Unknown action: "${action as string}"`);
|
||||
}
|
||||
@@ -552,6 +568,72 @@ export class DrizzleExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Contextual agent: composite read op
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private handleGetPageDetails(payload: WsToolCall): Record<string, unknown> {
|
||||
const db = getDb();
|
||||
// entity_type is sent as the `table` field by the backend execute_on_client call.
|
||||
const entityType = payload.table ?? '';
|
||||
const entityId = (payload.data?.['entityId'] as string | null | undefined) ?? undefined;
|
||||
|
||||
switch (entityType) {
|
||||
case 'project': {
|
||||
if (!entityId) throw new ExecutorError('get_page_details: entityId required for project');
|
||||
const project = db.select().from(projects).where(eq(projects.id, entityId)).get() ?? null;
|
||||
if (!project) return { project: null, tasks: [], notes: [], milestones: [], events: [] };
|
||||
const projectTasks = db.select().from(tasks).where(eq(tasks.projectId, entityId)).all();
|
||||
const projectNotes = db
|
||||
.select({
|
||||
id: notes.id,
|
||||
title: notes.title,
|
||||
aiSummary: notes.aiSummary,
|
||||
updatedAt: notes.updatedAt,
|
||||
})
|
||||
.from(notes)
|
||||
.where(eq(notes.projectId, entityId))
|
||||
.all();
|
||||
const events = db.select().from(timelineEvents).where(eq(timelineEvents.projectId, entityId)).all();
|
||||
return {
|
||||
project,
|
||||
tasks: projectTasks,
|
||||
notes: projectNotes,
|
||||
milestones: events.filter((e) => e.type === 'milestone'),
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
case 'task': {
|
||||
if (!entityId) throw new ExecutorError('get_page_details: entityId required for task');
|
||||
const task = db.select().from(tasks).where(eq(tasks.id, entityId)).get() ?? null;
|
||||
const project = task?.projectId
|
||||
? (db.select().from(projects).where(eq(projects.id, task.projectId)).get() ?? null)
|
||||
: null;
|
||||
const comments = db.select().from(taskComments).where(eq(taskComments.taskId, entityId)).all();
|
||||
return { task, project, comments };
|
||||
}
|
||||
|
||||
case 'note': {
|
||||
if (!entityId) throw new ExecutorError('get_page_details: entityId required for note');
|
||||
const note = db.select().from(notes).where(eq(notes.id, entityId)).get() ?? null;
|
||||
return { note };
|
||||
}
|
||||
|
||||
case 'tasks_all':
|
||||
return { tasks: db.select().from(tasks).all() };
|
||||
|
||||
case 'projects_all':
|
||||
return { projects: db.select().from(projects).all() };
|
||||
|
||||
case 'timeline_all':
|
||||
return { events: db.select().from(timelineEvents).all() };
|
||||
|
||||
default:
|
||||
throw new ExecutorError(`get_page_details: unknown entityType "${entityType}"`);
|
||||
}
|
||||
}
|
||||
|
||||
private handleListProjectsWithFolderManifests(): Record<string, unknown> {
|
||||
const projs = getDb()
|
||||
.select()
|
||||
|
||||
23
src/main/db/migrations/0006_misty_cammi.sql
Normal file
23
src/main/db/migrations/0006_misty_cammi.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE `ai_chat_messages` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`session_id` text NOT NULL,
|
||||
`role` text NOT NULL,
|
||||
`content` text NOT NULL,
|
||||
`tool_calls` text,
|
||||
`tool_results` text,
|
||||
`scope` text,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `ai_chat_sessions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`channel` text NOT NULL,
|
||||
`title` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
`last_scope` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS `ai_chat_messages_session_created_idx` ON `ai_chat_messages` (`session_id`, `created_at`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS `ai_chat_sessions_channel_updated_idx` ON `ai_chat_sessions` (`channel`, `updated_at`);
|
||||
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
@@ -43,6 +43,13 @@
|
||||
"when": 1778579196669,
|
||||
"tag": "0005_slim_baron_strucker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1778777130582,
|
||||
"tag": "0006_misty_cammi",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -197,3 +197,28 @@ export type NewAgentRunAction = InferInsertModel<typeof agentRunActions>;
|
||||
|
||||
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>;
|
||||
|
||||
@@ -110,6 +110,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.
|
||||
|
||||
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 };
|
||||
}),
|
||||
});
|
||||
@@ -13,11 +13,12 @@ import { getStore, getDeviceId, getLocalAgents, getLocalAgent, saveLocalAgent, d
|
||||
import type { LocalAgentLocalConfig } 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 { orchestrate, orchestrateContextual, orchestrateTaskBriefResearch, dailyBrief, getCachedBrief, invalidateBriefCache } from '../ai/orchestrator';
|
||||
import { getAuthManager, AuthError } from '../auth/auth-manager';
|
||||
import { detectFormatPrefs, detectLanguage } from '../auth/locale-defaults';
|
||||
import type { TRPCContext } from '../ipc';
|
||||
import { projectFoldersRouter } from './projectFolders';
|
||||
import { aiChatRouter } from './ai-chat';
|
||||
|
||||
const t = initTRPC.context<TRPCContext>().create();
|
||||
|
||||
@@ -929,25 +930,20 @@ const aiRouter = router({
|
||||
content: z.string(),
|
||||
})).optional(),
|
||||
sessionId: z.string().optional(),
|
||||
mode: z.enum(['home', 'floating']).optional(),
|
||||
scope: z.object({
|
||||
type: z.enum(['task', 'project', 'note', 'timeline']),
|
||||
id: z.string().optional(),
|
||||
}).optional(),
|
||||
mode: z.enum(['contextual']).optional(),
|
||||
scope: z.unknown().optional(),
|
||||
briefMode: z.boolean().optional(),
|
||||
briefingContext: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
if (input.mode === 'floating' && input.scope) {
|
||||
return await orchestrateFloating({
|
||||
if (input.mode === 'contextual') {
|
||||
return await orchestrateContextual({
|
||||
message: input.message,
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
scope: input.scope,
|
||||
conversationHistory: input.conversationHistory,
|
||||
briefMode: input.briefMode,
|
||||
briefingContext: input.briefingContext,
|
||||
conversationHistory: input.conversationHistory as Array<{ role: string; content: string }> | undefined,
|
||||
sender: ctx.sender,
|
||||
});
|
||||
}
|
||||
@@ -1856,6 +1852,7 @@ export const appRouter = router({
|
||||
agent: agentRouter,
|
||||
memory: memoryRouter,
|
||||
projectFolders: projectFoldersRouter,
|
||||
aiChat: aiChatRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -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,9 @@ 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),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
<ChatSurface
|
||||
variant="home"
|
||||
messages={messages}
|
||||
streamingContent={streamingContent}
|
||||
isStreaming={isStreaming}
|
||||
onSend={handleSend}
|
||||
cacheKey={cacheKey}
|
||||
aiMinHeight={aiMinHeight}
|
||||
lastUserMsgRef={lastUserMsgRef}
|
||||
lastAiRef={lastAiRef}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Non-home messages */}
|
||||
@@ -696,121 +571,6 @@ export function AIChatPanel({
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- AIMessage: shared layout for completed + streaming AI turns ---------- */
|
||||
|
||||
interface AIMessageProps {
|
||||
content: string;
|
||||
bottomPad?: boolean;
|
||||
minHeight?: number | null;
|
||||
skeleton?: boolean;
|
||||
}
|
||||
|
||||
const AIMessage = memo(forwardRef<HTMLDivElement, AIMessageProps>(
|
||||
({ content, bottomPad, minHeight, skeleton }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`mr-auto ${hasInlineTags(content) ? 'w-full' : 'max-w-[75%]'}`}
|
||||
style={minHeight ? { minHeight } : undefined}
|
||||
>
|
||||
<div className="flex items-end gap-2.5 mb-1">
|
||||
<Sparkles size={24} className="text-foreground" />
|
||||
<span className="text-xl font-semibold leading-none">adiuv<span className="font-bold text-primary">AI</span></span>
|
||||
</div>
|
||||
{skeleton ? (
|
||||
<div className="space-y-2 pl-[32px] pb-40">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
) : (
|
||||
<div className={`pl-[32px] flex flex-col gap-3${bottomPad ? ' pb-40' : ''}`}>
|
||||
<MessageContent content={content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
));
|
||||
AIMessage.displayName = 'AIMessage';
|
||||
|
||||
/* ---------- MessageContent: text with inline entity blocks ---------- */
|
||||
|
||||
const MessageContent = memo(function MessageContent({ content, fontSize }: { content: string; fontSize?: string }) {
|
||||
const segments = useMemo(
|
||||
() => mergeConsecutiveTaskSegments(mergeTimelineSegments(parseInlineTags(content))),
|
||||
[content],
|
||||
);
|
||||
|
||||
// Fast path: no inline tags, just render markdown
|
||||
if (segments.length === 1 && segments[0]?.type === 'text') {
|
||||
return <ChatMarkdown content={content} fontSize={fontSize} />;
|
||||
}
|
||||
|
||||
// No content at all
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{segments.map((seg, i) => {
|
||||
if (seg.type === 'text') {
|
||||
return <ChatMarkdown key={i} content={seg.content} fontSize={fontSize} />;
|
||||
}
|
||||
if (seg.type === 'chart') {
|
||||
return (
|
||||
<motion.div key={i} {...blockAnimation}>
|
||||
<ChatChartBlock data={seg.data} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<motion.div key={i} {...blockAnimation}>
|
||||
<ChatEntityBlock data={{ entity: seg.entity, ids: seg.ids }} />
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const blockAnimation = {
|
||||
initial: { opacity: 0, scale: 0.95 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
transition: { type: 'spring' as const, stiffness: 400, damping: 30 },
|
||||
};
|
||||
|
||||
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
|
||||
|
||||
// Stable references — defined outside the component so react-markdown never
|
||||
// sees a changed prop reference and re-parses content on every render.
|
||||
const REMARK_PLUGINS: Parameters<typeof ReactMarkdown>[0]['remarkPlugins'] = [remarkGfm];
|
||||
const MARKDOWN_COMPONENTS: Parameters<typeof ReactMarkdown>[0]['components'] = {
|
||||
pre: ({ children }) => (
|
||||
<pre className="bg-muted rounded-lg p-3 overflow-x-auto text-xs">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
code: ({ children, className }: { children?: React.ReactNode; className?: string }) => {
|
||||
if (!className) {
|
||||
return (
|
||||
<code className="bg-muted rounded px-1.5 py-0.5 text-xs font-mono">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return <code className={className}>{children}</code>;
|
||||
},
|
||||
};
|
||||
|
||||
export function ChatMarkdown({ content, size = 'sm', fontSize }: { content: string; size?: 'sm' | 'lg'; fontSize?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`prose dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${size === 'lg' ? 'prose-base' : 'prose-sm'}`}
|
||||
style={fontSize ? { fontSize } : undefined}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={REMARK_PLUGINS}
|
||||
components={MARKDOWN_COMPONENTS}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Re-export shared rendering utilities for consumers that previously imported
|
||||
// them directly from AIChatPanel.
|
||||
export { ChatMarkdown } from './ChatSurface';
|
||||
|
||||
17
src/renderer/components/ai/AdiuvaIcon.tsx
Normal file
17
src/renderer/components/ai/AdiuvaIcon.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export interface AdiuvaIconProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function AdiuvaIcon({ size = 24 }: AdiuvaIconProps) {
|
||||
return (
|
||||
<img
|
||||
src="/logo/logo-mark.svg"
|
||||
width={size}
|
||||
height={size}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className="adiuva-mark-img select-none pointer-events-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
17
src/renderer/components/ai/AdiuvaTriggerButton.tsx
Normal file
17
src/renderer/components/ai/AdiuvaTriggerButton.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useContextualChat } from '@/context/ContextualChatContext';
|
||||
import { AdiuvaIcon } from './AdiuvaIcon';
|
||||
|
||||
export function AdiuvaTriggerButton() {
|
||||
const { toggle, open } = useContextualChat();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
title="Ask adiuvAI"
|
||||
aria-pressed={open}
|
||||
className="adiuva-btn sm"
|
||||
>
|
||||
<AdiuvaIcon size={24} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export interface ChatInputBoxHandle {
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
type ChatInputBoxVariant = 'panel' | 'floating' | 'comment';
|
||||
type ChatInputBoxVariant = 'panel' | 'comment';
|
||||
|
||||
interface ChatInputBoxProps {
|
||||
cacheKey: string;
|
||||
@@ -27,12 +27,6 @@ const VARIANT_STYLES = {
|
||||
button: 'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100',
|
||||
iconSize: 16,
|
||||
},
|
||||
floating: {
|
||||
container: 'flex items-center gap-2 px-3 py-2.5',
|
||||
textarea: 'flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground/60 outline-none max-h-20 overflow-y-auto',
|
||||
button: 'flex h-7 w-7 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed',
|
||||
iconSize: 14,
|
||||
},
|
||||
comment: {
|
||||
container: 'flex items-center gap-2 px-3 py-2',
|
||||
textarea: 'flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground outline-none max-h-32 overflow-y-auto',
|
||||
@@ -49,7 +43,7 @@ export const ChatInputBox = forwardRef<ChatInputBoxHandle, ChatInputBoxProps>(
|
||||
const valueRef = useRef(value);
|
||||
valueRef.current = value;
|
||||
|
||||
// Re-init when the cache key changes (context switches in FloatingChat).
|
||||
// Re-init when the cache key changes (context switches).
|
||||
const prevKeyRef = useRef(cacheKey);
|
||||
useEffect(() => {
|
||||
if (prevKeyRef.current !== cacheKey) {
|
||||
|
||||
502
src/renderer/components/ai/ChatSurface.tsx
Normal file
502
src/renderer/components/ai/ChatSurface.tsx
Normal file
@@ -0,0 +1,502 @@
|
||||
/**
|
||||
* ChatSurface — pure presentational chat surface.
|
||||
*
|
||||
* Contains:
|
||||
* - Message list rendering (user bubbles, AI messages with Sparkles header,
|
||||
* inline entity/chart block parsing, error styling)
|
||||
* - Streaming content placeholder
|
||||
* - Scroll management
|
||||
* - ChatInputBox wrapper
|
||||
*
|
||||
* Also exports shared rendering utilities (ChatMarkdown, MessageContent,
|
||||
* AIMessage) so AIChatPanel can import them here and avoid circular deps.
|
||||
*/
|
||||
|
||||
import {
|
||||
memo,
|
||||
forwardRef,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { ChatEntityBlock } from './blocks/ChatEntityBlock';
|
||||
import { ChatChartBlock } from './blocks/ChatChartBlock';
|
||||
import { ChatInputBox, type ChatInputBoxHandle } from './ChatInputBox';
|
||||
import type { ChatMessage } from '@/hooks/useAIChat';
|
||||
import type { EntityRefBlockData, ChartBlockData } from '../../../shared/api-types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline tag parsing (mirrors AIChatPanel)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ENTITY_TAG_RE =
|
||||
/<(?<entity>task|project|note|timeline|timelineEvent)>(?:\[(?<bracketIds>[^\]]+)\]|(?<plainIds>[^<]+))<\/\k<entity>>/;
|
||||
const CHART_TAG_RE = /<chart>(?<chartJson>\{[\s\S]*?\})<\/chart>/;
|
||||
const INLINE_TAG_RE = new RegExp(`${ENTITY_TAG_RE.source}|${CHART_TAG_RE.source}`);
|
||||
|
||||
type ContentSegment =
|
||||
| { type: 'text'; content: string }
|
||||
| { type: 'entity'; entity: EntityRefBlockData['entity']; ids: string[] }
|
||||
| { type: 'chart'; data: ChartBlockData };
|
||||
|
||||
function parseInlineTags(content: string): ContentSegment[] {
|
||||
const segments: ContentSegment[] = [];
|
||||
let remaining = content;
|
||||
|
||||
while (remaining) {
|
||||
const match = INLINE_TAG_RE.exec(remaining);
|
||||
if (!match) {
|
||||
segments.push({ type: 'text', content: remaining });
|
||||
break;
|
||||
}
|
||||
|
||||
const before = remaining.slice(0, match.index);
|
||||
if (before) segments.push({ type: 'text', content: before });
|
||||
|
||||
const groups = match.groups ?? {};
|
||||
|
||||
if (groups.entity) {
|
||||
const entity = groups.entity as EntityRefBlockData['entity'];
|
||||
const rawIds = groups.bracketIds ?? groups.plainIds ?? '';
|
||||
const ids = rawIds
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean);
|
||||
segments.push({ type: 'entity', entity, ids });
|
||||
} else if (groups.chartJson) {
|
||||
try {
|
||||
const chartData = JSON.parse(groups.chartJson) as ChartBlockData;
|
||||
segments.push({ type: 'chart', data: chartData });
|
||||
} catch {
|
||||
segments.push({ type: 'text', content: match[0] });
|
||||
}
|
||||
}
|
||||
|
||||
const matchIndex = typeof match.index === 'number' ? match.index : 0;
|
||||
remaining = remaining.slice(matchIndex + match[0].length);
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function hasInlineTags(content: string): boolean {
|
||||
return INLINE_TAG_RE.test(content);
|
||||
}
|
||||
|
||||
function mergeTimelineSegments(segments: ContentSegment[]): ContentSegment[] {
|
||||
const allTimelineIds: string[] = [];
|
||||
for (const seg of segments) {
|
||||
if (seg.type === 'entity' && (seg.entity === 'timeline' || seg.entity === 'timelineEvent')) {
|
||||
allTimelineIds.push(...seg.ids);
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueTimelineIds = [...new Set(allTimelineIds)];
|
||||
if (!uniqueTimelineIds.length) return segments;
|
||||
|
||||
const merged: ContentSegment[] = [];
|
||||
let lastTimelineInsertIndex = 0;
|
||||
|
||||
for (const seg of segments) {
|
||||
if (seg.type === 'entity' && (seg.entity === 'timeline' || seg.entity === 'timelineEvent')) {
|
||||
lastTimelineInsertIndex = merged.length;
|
||||
continue;
|
||||
}
|
||||
merged.push(seg);
|
||||
}
|
||||
|
||||
merged.splice(lastTimelineInsertIndex, 0, {
|
||||
type: 'entity',
|
||||
entity: 'timeline',
|
||||
ids: uniqueTimelineIds,
|
||||
});
|
||||
|
||||
return merged.filter((seg) => !(seg.type === 'text' && !seg.content.trim()));
|
||||
}
|
||||
|
||||
function mergeConsecutiveTaskSegments(segments: ContentSegment[]): ContentSegment[] {
|
||||
const merged: ContentSegment[] = [];
|
||||
|
||||
for (let i = 0; i < segments.length; i += 1) {
|
||||
const current = segments[i];
|
||||
if (!current) continue;
|
||||
|
||||
if (!(current?.type === 'entity' && current.entity === 'task')) {
|
||||
merged.push(current);
|
||||
continue;
|
||||
}
|
||||
|
||||
const groupedIds: string[] = [...current.ids];
|
||||
let j = i + 1;
|
||||
|
||||
while (j < segments.length) {
|
||||
const next = segments[j];
|
||||
if (next?.type === 'text' && !next.content.trim()) {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
if (next?.type === 'entity' && next.entity === 'task') {
|
||||
groupedIds.push(...next.ids);
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
merged.push({
|
||||
type: 'entity',
|
||||
entity: 'task',
|
||||
ids: [...new Set(groupedIds)],
|
||||
});
|
||||
|
||||
i = j - 1;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChatMarkdown — lightweight markdown renderer with GFM + styled code blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const REMARK_PLUGINS: Parameters<typeof ReactMarkdown>[0]['remarkPlugins'] = [remarkGfm];
|
||||
const MARKDOWN_COMPONENTS: Parameters<typeof ReactMarkdown>[0]['components'] = {
|
||||
pre: ({ children }) => (
|
||||
<pre className="bg-muted rounded-lg p-3 overflow-x-auto text-xs">{children}</pre>
|
||||
),
|
||||
code: ({ children, className }: { children?: React.ReactNode; className?: string }) => {
|
||||
if (!className) {
|
||||
return (
|
||||
<code className="bg-muted rounded px-1.5 py-0.5 text-xs font-mono">{children}</code>
|
||||
);
|
||||
}
|
||||
return <code className={className}>{children}</code>;
|
||||
},
|
||||
};
|
||||
|
||||
export function ChatMarkdown({
|
||||
content,
|
||||
size = 'sm',
|
||||
fontSize,
|
||||
}: {
|
||||
content: string;
|
||||
size?: 'sm' | 'lg';
|
||||
fontSize?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`prose dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${size === 'lg' ? 'prose-base' : 'prose-sm'}`}
|
||||
style={fontSize ? { fontSize } : undefined}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MessageContent — text with inline entity + chart blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const blockAnimation = {
|
||||
initial: { opacity: 0, scale: 0.95 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
transition: { type: 'spring' as const, stiffness: 400, damping: 30 },
|
||||
};
|
||||
|
||||
export const MessageContent = memo(function MessageContent({
|
||||
content,
|
||||
fontSize,
|
||||
}: {
|
||||
content: string;
|
||||
fontSize?: string;
|
||||
}) {
|
||||
const segments = useMemo(
|
||||
() => mergeConsecutiveTaskSegments(mergeTimelineSegments(parseInlineTags(content))),
|
||||
[content],
|
||||
);
|
||||
|
||||
if (segments.length === 1 && segments[0]?.type === 'text') {
|
||||
return <ChatMarkdown content={content} fontSize={fontSize} />;
|
||||
}
|
||||
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{segments.map((seg, i) => {
|
||||
if (seg.type === 'text') {
|
||||
return <ChatMarkdown key={i} content={seg.content} fontSize={fontSize} />;
|
||||
}
|
||||
if (seg.type === 'chart') {
|
||||
return (
|
||||
<motion.div key={i} {...blockAnimation}>
|
||||
<ChatChartBlock data={seg.data} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<motion.div key={i} {...blockAnimation}>
|
||||
<ChatEntityBlock data={{ entity: seg.entity, ids: seg.ids }} />
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AIMessage — shared layout for completed + streaming AI turns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AIMessageProps {
|
||||
content: string;
|
||||
bottomPad?: boolean;
|
||||
minHeight?: number | null;
|
||||
skeleton?: boolean;
|
||||
}
|
||||
|
||||
export const AIMessage = memo(
|
||||
forwardRef<HTMLDivElement, AIMessageProps>(({ content, bottomPad, minHeight, skeleton }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`mr-auto ${hasInlineTags(content) ? 'w-full' : 'max-w-[75%]'}`}
|
||||
style={minHeight ? { minHeight } : undefined}
|
||||
>
|
||||
<div className="flex items-end gap-2.5 mb-1">
|
||||
<Sparkles size={24} className="text-foreground" />
|
||||
<span className="text-xl font-semibold leading-none">
|
||||
adiuv<span className="font-bold text-primary">AI</span>
|
||||
</span>
|
||||
</div>
|
||||
{skeleton ? (
|
||||
<div className="space-y-2 pl-[32px] pb-40">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
) : (
|
||||
<div className={`pl-[32px] flex flex-col gap-3${bottomPad ? ' pb-40' : ''}`}>
|
||||
<MessageContent content={content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)),
|
||||
);
|
||||
AIMessage.displayName = 'AIMessage';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChatSurface props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ChatSurfaceProps {
|
||||
messages: ChatMessage[];
|
||||
streamingContent: string;
|
||||
isStreaming: boolean;
|
||||
onSend: (text: string) => void;
|
||||
cacheKey: string;
|
||||
variant: 'home' | 'contextual';
|
||||
/** Slot rendered just above the input area (e.g. suggestion chips). */
|
||||
aboveInputSlot?: React.ReactNode;
|
||||
/** Extra bottom padding for the message list (default 120px). */
|
||||
bottomPadPx?: number;
|
||||
/** Ref forwarded to the ChatInputBox for imperative control. */
|
||||
inputRef?: React.Ref<ChatInputBoxHandle>;
|
||||
/** minHeight applied to the last AI message (home page scroll behaviour). */
|
||||
aiMinHeight?: number | null;
|
||||
/** Ref set on the last user message div for scroll targeting. */
|
||||
lastUserMsgRef?: React.RefObject<HTMLDivElement | null>;
|
||||
/** Ref set on the last AI message div. */
|
||||
lastAiRef?: React.RefObject<HTMLDivElement | null>;
|
||||
/** Additional class names for the scroll area viewport. */
|
||||
viewportClassName?: string;
|
||||
/** Whether the scroll area has messages (controls scrollbar z-index). */
|
||||
hasMessages?: boolean;
|
||||
/** i18n placeholder for the input field. */
|
||||
placeholder?: string;
|
||||
/** Hint shown when messages are empty and not streaming. Used by the contextual variant. */
|
||||
emptyStateCopy?: React.ReactNode;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChatSurface component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ChatSurface = memo(function ChatSurface({
|
||||
messages,
|
||||
streamingContent,
|
||||
isStreaming,
|
||||
onSend,
|
||||
cacheKey,
|
||||
variant,
|
||||
aboveInputSlot,
|
||||
inputRef,
|
||||
aiMinHeight,
|
||||
lastUserMsgRef,
|
||||
lastAiRef,
|
||||
viewportClassName,
|
||||
hasMessages,
|
||||
placeholder,
|
||||
emptyStateCopy,
|
||||
}: ChatSurfaceProps) {
|
||||
// Internal scroll ref — used only in contextual variant where we don't have
|
||||
// the parent-managed scroll refs from the home path.
|
||||
const internalScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (variant !== 'contextual') return;
|
||||
internalScrollRef.current?.scrollTo({
|
||||
top: internalScrollRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [messages.length, streamingContent, variant]);
|
||||
|
||||
if (variant === 'home') {
|
||||
// Home variant: delegates scroll management entirely to the parent
|
||||
// (AIChatPanel owns ScrollArea + scroll-to-user-message logic).
|
||||
// Renders only the message list rows + fixed input footer.
|
||||
return (
|
||||
<>
|
||||
{/* Message list — rendered inside parent's ScrollArea */}
|
||||
<div className="mx-auto w-full max-w-6xl px-6 pt-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div aria-hidden style={{ height: '4vw', flexShrink: 0 }} />
|
||||
|
||||
{messages.map((msg, idx) => {
|
||||
const isLast = idx === messages.length - 1;
|
||||
const isLastUser = isLast && msg.role === 'user';
|
||||
const isLastAssistant = isLast && msg.role === 'assistant' && !isStreaming;
|
||||
|
||||
if (msg.role === 'user') {
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
ref={isLastUser ? lastUserMsgRef : undefined}
|
||||
className="flex justify-end"
|
||||
>
|
||||
<div className="ml-auto max-w-[75%] rounded-2xl bg-muted px-4 py-2">
|
||||
<ChatMarkdown content={msg.content} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.error) {
|
||||
return (
|
||||
<div key={msg.id} className="mr-auto max-w-[75%]">
|
||||
<p className="text-destructive whitespace-pre-wrap">{msg.content}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AIMessage
|
||||
key={msg.id}
|
||||
ref={isLastAssistant ? lastAiRef : undefined}
|
||||
content={msg.content}
|
||||
bottomPad={isLastAssistant}
|
||||
minHeight={isLastAssistant ? aiMinHeight : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{isStreaming && (
|
||||
<AIMessage
|
||||
ref={lastAiRef}
|
||||
content={streamingContent}
|
||||
bottomPad
|
||||
minHeight={aiMinHeight}
|
||||
skeleton={!streamingContent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Above-input slot (suggestion chips, etc.) rendered by parent */}
|
||||
{aboveInputSlot}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contextual variant — self-contained scroll + absolute-positioned input
|
||||
// ---------------------------------------------------------------------------
|
||||
return (
|
||||
<div className="flex flex-col h-full relative">
|
||||
<ScrollArea
|
||||
className="h-full"
|
||||
scrollbarClassName={hasMessages ? 'z-30' : undefined}
|
||||
viewportClassName={viewportClassName}
|
||||
>
|
||||
<div
|
||||
ref={internalScrollRef}
|
||||
className="flex flex-col gap-4 px-4"
|
||||
style={{ paddingBottom: 120, paddingTop: 64 }}
|
||||
>
|
||||
{messages.length === 0 && !isStreaming && variant === 'contextual' && emptyStateCopy && (
|
||||
<div className="text-center text-xs text-muted-foreground py-12 px-6 leading-relaxed">
|
||||
{emptyStateCopy}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => {
|
||||
if (msg.role === 'user') {
|
||||
return (
|
||||
<div key={msg.id} className="flex justify-end">
|
||||
<div className="ml-auto max-w-[75%] rounded-2xl bg-muted px-4 py-2">
|
||||
<ChatMarkdown content={msg.content} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.error) {
|
||||
return (
|
||||
<div key={msg.id} className="mr-auto max-w-[75%]">
|
||||
<p className="text-destructive whitespace-pre-wrap">{msg.content}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <AIMessage key={msg.id} content={msg.content} />;
|
||||
})}
|
||||
|
||||
{isStreaming && (
|
||||
<AIMessage
|
||||
content={streamingContent}
|
||||
skeleton={!streamingContent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{aboveInputSlot}
|
||||
|
||||
{/* Absolute-positioned input with gradient fade */}
|
||||
<div className="absolute inset-x-0 bottom-0 px-4 pb-3 pointer-events-none">
|
||||
<div
|
||||
className="h-16 -mx-4 -mt-16 pointer-events-none"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to bottom, transparent 0%, color-mix(in srgb, var(--background) 90%, transparent) 60%, var(--background) 100%)',
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-auto relative rounded-2xl bg-background/70 backdrop-blur-xl border border-border/50 shadow-lg ring-1 ring-border/20 transition-shadow focus-within:shadow-xl focus-within:border-ring/50">
|
||||
<ChatInputBox
|
||||
ref={inputRef}
|
||||
onSend={onSend}
|
||||
isStreaming={isStreaming}
|
||||
cacheKey={cacheKey}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
85
src/renderer/components/ai/ContextualSidebar.tsx
Normal file
85
src/renderer/components/ai/ContextualSidebar.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useMemo } from 'react';
|
||||
import { SquarePen } from 'lucide-react';
|
||||
import { useContextualChat, type ContextualScope } from '@/context/ContextualChatContext';
|
||||
import { ChatSurface } from './ChatSurface';
|
||||
|
||||
function scopeLabel(scope: ContextualScope | null): string | null {
|
||||
if (!scope) return null;
|
||||
switch (scope.page) {
|
||||
case 'timeline':
|
||||
return 'Timeline';
|
||||
case 'tasks':
|
||||
return 'Tasks';
|
||||
case 'projects-list':
|
||||
return 'Projects';
|
||||
case 'project':
|
||||
return scope.entityName ? `Project · ${scope.entityName}` : 'Project';
|
||||
case 'note':
|
||||
return scope.entityName ? `Note · ${scope.entityName}` : 'Note';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ContextualSidebar() {
|
||||
const { messages, isStreaming, streamingContent, send, newChat, sessionId, scope } =
|
||||
useContextualChat();
|
||||
const label = scopeLabel(scope);
|
||||
|
||||
const emptyStateCopy = useMemo(() => {
|
||||
if (!scope) return null;
|
||||
switch (scope.page) {
|
||||
case 'tasks':
|
||||
return 'Ask anything about your tasks — or "create a task for…"';
|
||||
case 'projects-list':
|
||||
return 'Ask about your projects, or kick off a new one.';
|
||||
case 'timeline':
|
||||
return "Ask about milestones, what's coming up, or what's overdue.";
|
||||
case 'project':
|
||||
return scope.entityName
|
||||
? `Ask anything about ${scope.entityName} — recap, tasks, status.`
|
||||
: null;
|
||||
case 'note':
|
||||
return scope.entityName
|
||||
? `Ask about ${scope.entityName}. (Note editing comes in a later release.)`
|
||||
: null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [scope]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full bg-transparent">
|
||||
<div className="absolute top-[10px] left-[8px] z-10 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void newChat();
|
||||
}}
|
||||
aria-label="New conversation"
|
||||
title="New chat"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-sm bg-background/60 text-muted-foreground backdrop-blur-md transition-colors hover:text-foreground hover:bg-accent"
|
||||
>
|
||||
<SquarePen size={14} />
|
||||
</button>
|
||||
{label && (
|
||||
<div
|
||||
className="inline-flex h-6 items-center rounded-sm bg-background/60 px-2 text-[11px] font-medium text-muted-foreground backdrop-blur-md"
|
||||
title={`Current context: ${label}`}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChatSurface
|
||||
messages={messages}
|
||||
streamingContent={streamingContent}
|
||||
isStreaming={isStreaming}
|
||||
onSend={send}
|
||||
cacheKey={`contextual:${sessionId ?? 'none'}`}
|
||||
variant="contextual"
|
||||
emptyStateCopy={emptyStateCopy}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,438 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useNavigate, useRouterState } from '@tanstack/react-router';
|
||||
import { X } from 'lucide-react';
|
||||
import {
|
||||
useFloatingChat,
|
||||
computeDualAnchor,
|
||||
getChatWidth,
|
||||
CHAT_HEIGHT,
|
||||
PADDING,
|
||||
} from '@/context/FloatingChatContext';
|
||||
import { useAIChat, type UIChatContext, type FloatingDomainSignal } from '@/hooks/useAIChat';
|
||||
import { ChatMarkdown } from '@/components/ai/AIChatPanel';
|
||||
import { ChatInputBox, type ChatInputBoxHandle } from '@/components/ai/ChatInputBox';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
/** Map floating_domain signals to routes for background navigation */
|
||||
const DOMAIN_ROUTES: Record<string, string> = {
|
||||
tasks: '/tasks',
|
||||
notes: '/notes',
|
||||
timelines: '/timeline',
|
||||
projects: '/projects',
|
||||
};
|
||||
|
||||
const DOMAIN_SECTION_IDS: Partial<Record<'tasks' | 'notes' | 'timelines' | 'projects', string>> = {
|
||||
tasks: 'tasks-list',
|
||||
timelines: 'timeline-chart',
|
||||
};
|
||||
|
||||
interface DomainNavigationTarget {
|
||||
route: '/tasks' | '/timeline' | '/projects' | '/notes/$noteId';
|
||||
sectionId?: string;
|
||||
projectId?: string;
|
||||
noteId?: string;
|
||||
nodeId?: string;
|
||||
}
|
||||
|
||||
function normalizeDomainSignal(domain: FloatingDomainSignal): DomainNavigationTarget | null {
|
||||
if (typeof domain === 'string') {
|
||||
const route = DOMAIN_ROUTES[domain];
|
||||
if (!route) return null;
|
||||
return {
|
||||
route: route as DomainNavigationTarget['route'],
|
||||
sectionId: DOMAIN_SECTION_IDS[domain as keyof typeof DOMAIN_SECTION_IDS],
|
||||
};
|
||||
}
|
||||
|
||||
switch (domain.type) {
|
||||
case 'task':
|
||||
return { route: '/tasks', sectionId: 'tasks-list' };
|
||||
case 'timeline':
|
||||
return { route: '/timeline', sectionId: 'timeline-chart' };
|
||||
case 'note':
|
||||
if (!domain.id) return { route: '/projects' };
|
||||
return { route: '/notes/$noteId', noteId: domain.id };
|
||||
case 'project': {
|
||||
if (domain.section === 'task') {
|
||||
return { route: '/projects', sectionId: 'project-tasks', projectId: domain.id ?? undefined };
|
||||
}
|
||||
if (domain.section === 'timeline') {
|
||||
return { route: '/projects', sectionId: 'project-timeline', projectId: domain.id ?? undefined };
|
||||
}
|
||||
if (domain.section === 'note') {
|
||||
return { route: '/projects', sectionId: 'project-notes', projectId: domain.id ?? undefined };
|
||||
}
|
||||
return { route: '/projects', projectId: domain.id ?? undefined };
|
||||
}
|
||||
case 'node':
|
||||
if (!domain.id) return null;
|
||||
return { route: '/projects', sectionId: domain.id, nodeId: domain.id };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function FloatingChatInner() {
|
||||
const { state, sections, close, updatePosition, setPendingSection, moveToSection } = useFloatingChat();
|
||||
const navigate = useNavigate();
|
||||
const routerState = useRouterState();
|
||||
const prevPathRef = useRef(routerState.location.pathname);
|
||||
const domainNavigationInFlightRef = useRef(false);
|
||||
|
||||
// Active section lookup
|
||||
const activeSection = sections.get(state.activeSectionId ?? '');
|
||||
|
||||
// Chat context — floating mode with scope derived from active section
|
||||
const chatContext = useMemo<UIChatContext>(() => {
|
||||
const scope = activeSection
|
||||
? {
|
||||
type: (activeSection.label?.toLowerCase().includes('task')
|
||||
? 'task'
|
||||
: activeSection.label?.toLowerCase().includes('note')
|
||||
? 'note'
|
||||
: activeSection.label?.toLowerCase().includes('timeline')
|
||||
? 'timeline'
|
||||
: 'project') as 'task' | 'project' | 'note' | 'timeline',
|
||||
id: activeSection.projectId,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
type: 'floating' as const,
|
||||
projectId: activeSection?.projectId,
|
||||
scope,
|
||||
};
|
||||
}, [activeSection?.projectId, activeSection?.label]);
|
||||
|
||||
// Handle floating_domain signals — navigate in background
|
||||
const handleDomainSignal = useCallback(
|
||||
(domainSignal: FloatingDomainSignal) => {
|
||||
const target = normalizeDomainSignal(domainSignal);
|
||||
if (!target) return;
|
||||
|
||||
// If backend points to a currently registered node/section, move there immediately.
|
||||
if (target.sectionId && sections.has(target.sectionId)) {
|
||||
moveToSection(target.sectionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPath = routerState.location.pathname;
|
||||
const isCurrentRoute =
|
||||
(target.route === '/projects' && currentPath === '/projects') ||
|
||||
(target.route === '/tasks' && currentPath === '/tasks') ||
|
||||
(target.route === '/timeline' && currentPath === '/timeline') ||
|
||||
(target.route === '/notes/$noteId' && currentPath.startsWith('/notes/'));
|
||||
|
||||
if (isCurrentRoute && target.sectionId) {
|
||||
setPendingSection({ sectionId: target.sectionId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCurrentRoute) return;
|
||||
|
||||
domainNavigationInFlightRef.current = true;
|
||||
|
||||
const pendingSectionId = target.sectionId;
|
||||
if (pendingSectionId) {
|
||||
setPendingSection({ sectionId: pendingSectionId });
|
||||
} else {
|
||||
setPendingSection(undefined);
|
||||
}
|
||||
|
||||
if (target.route === '/projects') {
|
||||
void navigate({ to: '/projects', search: target.projectId ? { projectId: target.projectId } : {} });
|
||||
} else if (target.route === '/notes/$noteId' && target.noteId) {
|
||||
void navigate({ to: '/notes/$noteId', params: { noteId: target.noteId } });
|
||||
} else if (target.route === '/tasks') {
|
||||
void navigate({ to: '/tasks' });
|
||||
} else if (target.route === '/timeline') {
|
||||
void navigate({ to: '/timeline' });
|
||||
}
|
||||
},
|
||||
[routerState.location.pathname, navigate, setPendingSection, sections, moveToSection],
|
||||
);
|
||||
|
||||
const {
|
||||
messages,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
handleSend,
|
||||
clearMessages,
|
||||
cacheKey,
|
||||
} = useAIChat(chatContext, { onDomainSignal: handleDomainSignal });
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ---- Close on Escape ----
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isOpen) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [state.isOpen, close]);
|
||||
|
||||
// ---- Close on route change (unless cross-page navigation pending) ----
|
||||
|
||||
// Tracks whether the most recent close was triggered by user navigation.
|
||||
// Used to decide whether to reset the session on close.
|
||||
const closeByNavigationRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const currentPath = routerState.location.pathname;
|
||||
if (prevPathRef.current !== currentPath && state.isOpen) {
|
||||
// Keep floating chat alive when navigation is AI-domain driven.
|
||||
if (domainNavigationInFlightRef.current) {
|
||||
domainNavigationInFlightRef.current = false;
|
||||
} else if (!state.pendingSection) {
|
||||
closeByNavigationRef.current = true;
|
||||
close();
|
||||
}
|
||||
}
|
||||
prevPathRef.current = currentPath;
|
||||
}, [routerState.location.pathname, state.isOpen, state.pendingSection, close]);
|
||||
|
||||
// ---- Clear messages on close ----
|
||||
|
||||
const prevOpenRef = useRef(state.isOpen);
|
||||
useEffect(() => {
|
||||
if (prevOpenRef.current && !state.isOpen) {
|
||||
const resetSession = closeByNavigationRef.current;
|
||||
closeByNavigationRef.current = false;
|
||||
// Clear input draft first so the unmount flush writes '' to the cache.
|
||||
inputRef.current?.clear();
|
||||
clearMessages(resetSession);
|
||||
}
|
||||
prevOpenRef.current = state.isOpen;
|
||||
}, [state.isOpen, clearMessages]);
|
||||
|
||||
// ---- Window resize: keep within bounds ----
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isOpen) return;
|
||||
const handler = () => {
|
||||
// Re-anchor if the container would go offscreen
|
||||
const el = containerRef.current;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth || rect.bottom > window.innerHeight) {
|
||||
el.style.left = `${Math.max(PADDING, Math.min(state.position.x, window.innerWidth - getChatWidth() - PADDING))}px`;
|
||||
el.style.top = `${Math.max(PADDING, Math.min(state.position.y, window.innerHeight - CHAT_HEIGHT - PADDING))}px`;
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', handler);
|
||||
return () => window.removeEventListener('resize', handler);
|
||||
}, [state.isOpen, state.position.x, state.position.y]);
|
||||
|
||||
// ---- Scroll tracking: dual-anchor repositioning ----
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isOpen || !state.activeSectionId) return;
|
||||
const section = sections.get(state.activeSectionId);
|
||||
if (!section || section.anchorMode === 'right-margin') return;
|
||||
|
||||
const el = section.ref.current;
|
||||
if (!el) return;
|
||||
|
||||
// Find scrollable ancestor
|
||||
let scrollParent: HTMLElement | null = el.parentElement;
|
||||
while (scrollParent) {
|
||||
const style = getComputedStyle(scrollParent);
|
||||
if (style.overflow === 'auto' || style.overflow === 'scroll' ||
|
||||
style.overflowY === 'auto' || style.overflowY === 'scroll') {
|
||||
break;
|
||||
}
|
||||
// Also check for Radix ScrollArea viewport
|
||||
if (scrollParent.hasAttribute('data-radix-scroll-area-viewport')) break;
|
||||
scrollParent = scrollParent.parentElement;
|
||||
}
|
||||
|
||||
if (!scrollParent) return;
|
||||
|
||||
let rafId: number | null = null;
|
||||
const handleScroll = () => {
|
||||
if (rafId !== null) return;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
const newPos = computeDualAnchor(section);
|
||||
if (newPos) {
|
||||
updatePosition(newPos);
|
||||
}
|
||||
// null = fully off-screen → freeze (do nothing)
|
||||
});
|
||||
};
|
||||
|
||||
scrollParent.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => {
|
||||
scrollParent.removeEventListener('scroll', handleScroll);
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [state.isOpen, state.activeSectionId, sections, updatePosition]);
|
||||
|
||||
// ---- Auto-scroll messages ----
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTo({ top: el.scrollHeight });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, streamingContent, scrollToBottom]);
|
||||
|
||||
// ---- Auto-focus input on open ----
|
||||
|
||||
const inputRef = useRef<ChatInputBoxHandle>(null);
|
||||
useEffect(() => {
|
||||
if (state.isOpen) {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [state.isOpen]);
|
||||
|
||||
const hasMessages = messages.length > 0 || isStreaming;
|
||||
|
||||
// Expand the messages panel upward if there's enough space above the input bar,
|
||||
// otherwise expand downward. 320px = 300px max-h + 8px gap + 12px buffer.
|
||||
const expandUp = state.position.y >= 320;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{state.isOpen && (
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
key="floating-chat"
|
||||
layout
|
||||
layoutId={state.morphTargetId ?? undefined}
|
||||
initial={{ opacity: 0, scale: 0.95, y: 12 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 12 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: state.position.x,
|
||||
top: state.position.y,
|
||||
width: state.position.width,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
className="relative"
|
||||
>
|
||||
{/* ---- Messages panel — floats above or below the input bar ---- */}
|
||||
<AnimatePresence>
|
||||
{hasMessages && (
|
||||
<motion.div
|
||||
key="messages-panel"
|
||||
initial={{ opacity: 0, scale: 0.97, y: expandUp ? 8 : -8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.97, y: expandUp ? 8 : -8 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
...(expandUp
|
||||
? { bottom: 'calc(100% + 8px)' }
|
||||
: { top: 'calc(100% + 8px)' }),
|
||||
}}
|
||||
className="rounded-2xl overflow-hidden"
|
||||
>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="max-h-[300px] overflow-y-auto rounded-2xl [&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/40"
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 p-3">
|
||||
{messages.map((msg) => {
|
||||
if (msg.role === 'user') {
|
||||
return (
|
||||
<div key={msg.id} className="flex justify-end">
|
||||
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-br-md px-3.5 py-2">
|
||||
<p className="text-xs whitespace-pre-wrap leading-relaxed text-foreground">
|
||||
{msg.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.error) {
|
||||
return (
|
||||
<div key={msg.id} className="flex justify-start">
|
||||
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2 !border-destructive/30">
|
||||
<p className="text-xs text-destructive whitespace-pre-wrap leading-relaxed">
|
||||
{msg.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={msg.id} className="flex justify-start">
|
||||
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2">
|
||||
<div className="text-xs text-foreground">
|
||||
<ChatMarkdown content={msg.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Streaming */}
|
||||
{isStreaming && (
|
||||
<div className="flex justify-start">
|
||||
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2">
|
||||
{streamingContent ? (
|
||||
<div className="text-xs text-foreground">
|
||||
<ChatMarkdown content={streamingContent} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 py-0.5">
|
||||
<Skeleton className="h-3 w-36" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ---- Floating input bar ---- */}
|
||||
<div className="glass-surface relative rounded-2xl transition-shadow focus-within:shadow-[0_8px_60px_-8px_rgba(0,0,0,0.35)]">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={close}
|
||||
className="absolute -top-1.5 -right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-muted/90 backdrop-blur-sm border border-border/50 shadow-sm hover:bg-muted transition-colors z-10"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
|
||||
<ChatInputBox
|
||||
ref={inputRef}
|
||||
variant="floating"
|
||||
cacheKey={cacheKey}
|
||||
isStreaming={isStreaming}
|
||||
onSend={handleSend}
|
||||
placeholder={`Ask about ${activeSection?.label ?? 'this section'}...`}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export function FloatingChatPortal() {
|
||||
return createPortal(<FloatingChatInner />, document.body);
|
||||
}
|
||||
@@ -177,7 +177,7 @@ export function TaskBriefChat({ taskId, projectId, initialBriefing, onBriefingRe
|
||||
message: trimmed,
|
||||
conversationHistory,
|
||||
sessionId,
|
||||
mode: 'floating',
|
||||
mode: 'contextual',
|
||||
scope: { type: 'task', id: taskId },
|
||||
briefMode: true,
|
||||
briefingContext: briefingText || undefined,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useState, useRef, useMemo } from 'react';
|
||||
import { Link, useRouterState, useNavigate } from '@tanstack/react-router';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { Link, useRouterState, useNavigate, useLocation } from '@tanstack/react-router';
|
||||
import { LayoutGroup } from 'framer-motion';
|
||||
import { ContextualChatProvider, useContextualChat } from '@/context/ContextualChatContext';
|
||||
import { ContextualSidebar } from '@/components/ai/ContextualSidebar';
|
||||
import { AdiuvaTriggerButton } from '@/components/ai/AdiuvaTriggerButton';
|
||||
import { HeaderProvider, useHeader } from '@/context/HeaderContext';
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
||||
import type { PanelSize } from 'react-resizable-panels';
|
||||
import {
|
||||
House,
|
||||
ChartGantt,
|
||||
@@ -19,7 +25,6 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { useDoubleClickAI } from '@/hooks/useDoubleClickAI';
|
||||
import { useTheme } from '@/components/theme-provider';
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -61,8 +66,6 @@ import {
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { AIChatPanel } from '@/components/ai/AIChatPanel';
|
||||
import { FloatingChatPortal } from '@/components/ai/FloatingChat';
|
||||
import { FloatingChatProvider } from '@/context/FloatingChatContext';
|
||||
import { ExpandedClientsProvider, useExpandedClients } from '@/context/ExpandedClientsContext';
|
||||
import { TaskBriefingProvider, useTaskBriefing } from '@/context/TaskBriefingContext';
|
||||
import { LoginForm } from '@/components/auth/LoginForm';
|
||||
@@ -76,26 +79,85 @@ const NAV_ITEMS = [
|
||||
{ to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
|
||||
] as const;
|
||||
|
||||
const SIDEBAR_SIZE_KEY = 'chat.sidebar.size';
|
||||
const SIDEBAR_SIZE_MIN = 22;
|
||||
const SIDEBAR_SIZE_MAX = 60;
|
||||
const SIDEBAR_SIZE_DEFAULT = 38;
|
||||
|
||||
function readSidebarSize(): number {
|
||||
if (typeof window === 'undefined') return SIDEBAR_SIZE_DEFAULT;
|
||||
const v = window.localStorage.getItem(SIDEBAR_SIZE_KEY);
|
||||
if (!v) return SIDEBAR_SIZE_DEFAULT;
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return SIDEBAR_SIZE_DEFAULT;
|
||||
return Math.max(SIDEBAR_SIZE_MIN, Math.min(SIDEBAR_SIZE_MAX, n));
|
||||
}
|
||||
|
||||
function MainArea({ children }: { children: React.ReactNode }) {
|
||||
const loc = useLocation();
|
||||
const isHome = loc.pathname === '/';
|
||||
const { open } = useContextualChat();
|
||||
// Read once per mount of the open state. When the user reopens the sidebar
|
||||
// we want the most recent persisted size, so we key the PanelGroup on
|
||||
// `open` so it remounts each open/close cycle.
|
||||
const initialSize = useMemo(() => readSidebarSize(), [open]);
|
||||
|
||||
if (isHome || !open) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
key={`sidebar-open-${initialSize}`}
|
||||
orientation="horizontal"
|
||||
className="h-full w-full"
|
||||
>
|
||||
<ResizablePanel defaultSize={`${100 - initialSize}%`} minSize="30%">
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
withHandle
|
||||
className="bg-border/40 hover:bg-border/70 transition-colors after:w-3! cursor-col-resize"
|
||||
/>
|
||||
<ResizablePanel
|
||||
defaultSize={`${initialSize}%`}
|
||||
minSize={`${SIDEBAR_SIZE_MIN}%`}
|
||||
maxSize={`${SIDEBAR_SIZE_MAX}%`}
|
||||
onResize={(panelSize: PanelSize) => {
|
||||
const clamped = Math.max(
|
||||
SIDEBAR_SIZE_MIN,
|
||||
Math.min(SIDEBAR_SIZE_MAX, panelSize.asPercentage),
|
||||
);
|
||||
window.localStorage.setItem(SIDEBAR_SIZE_KEY, String(clamped));
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-full">
|
||||
<ContextualSidebar />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
|
||||
interface AppShellProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
return (
|
||||
<FloatingChatProvider>
|
||||
<ExpandedClientsProvider>
|
||||
<TaskBriefingProvider>
|
||||
<HeaderProvider>
|
||||
<div className="flex w-full h-full">
|
||||
<AppShellInner>{children}</AppShellInner>
|
||||
</div>
|
||||
</HeaderProvider>
|
||||
</TaskBriefingProvider>
|
||||
</ExpandedClientsProvider>
|
||||
</FloatingChatProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppShellInner({ children }: AppShellProps) {
|
||||
useDoubleClickAI();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const authStatusQuery = trpc.auth.status.useQuery(undefined, {
|
||||
@@ -125,18 +187,22 @@ function AppShellInner({ children }: AppShellProps) {
|
||||
const [homeChatHasMessages, setHomeChatHasMessages] = useState(false);
|
||||
|
||||
const isHomePage = currentPath === '/';
|
||||
const isProjectsPage = currentPath.startsWith('/projects');
|
||||
const isNotesPage = currentPath.startsWith('/notes');
|
||||
const isSettingsPage = currentPath.startsWith('/settings');
|
||||
|
||||
// Derive the page label from the current path for the breadcrumb
|
||||
const matchedItem = NAV_ITEMS.find(
|
||||
(item) => item.to !== '/' && currentPath.startsWith(item.to),
|
||||
);
|
||||
const pageLabel = matchedItem ? t(matchedItem.labelKey) : (currentPath.startsWith('/settings') ? t('nav.settings') : '');
|
||||
const routeLabel = matchedItem ? t(matchedItem.labelKey) : (currentPath.startsWith('/settings') ? t('nav.settings') : '');
|
||||
|
||||
// Pages with their own header (SidebarTrigger integrated) hide the global one
|
||||
const showHeader = !isProjectsPage && !isNotesPage && !isSettingsPage && !isHomePage;
|
||||
// Dynamic label/extras published by child pages (e.g. ProjectDetail)
|
||||
const { label: dynamicLabel, extras: headerExtras, leftExtras, rightExtras } = useHeader();
|
||||
const pageLabel = dynamicLabel ?? routeLabel;
|
||||
|
||||
// All non-home, non-settings routes show the shared AppShell header.
|
||||
// Projects and notes previously managed their own header; they now receive
|
||||
// the shared header (with SidebarTrigger + AdiuvaTriggerButton) from here.
|
||||
const showHeader = !isSettingsPage && !isHomePage;
|
||||
|
||||
if (authStatusQuery.data?.authenticated === false) {
|
||||
return <LoginForm />;
|
||||
@@ -157,26 +223,6 @@ function AppShellInner({ children }: AppShellProps) {
|
||||
profile={authStatusQuery.data?.profile ?? null}
|
||||
/>
|
||||
<SidebarInset className="min-w-0 min-h-0 overflow-x-hidden">
|
||||
{showHeader && (
|
||||
<header className="flex h-14 shrink-0 items-center gap-2">
|
||||
<div className="flex flex-1 items-center gap-2 px-3">
|
||||
<SidebarTrigger />
|
||||
{!isHomePage && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px mr-2 data-[orientation=vertical]:h-4" />
|
||||
{/* <Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{pageLabel}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb> */}
|
||||
<h4 className="text-sm font-medium text-foreground flex-1">{pageLabel}</h4>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
{isHomePage ? (
|
||||
<div className="relative flex-1 min-h-0">
|
||||
{!taskBriefing.isOpen && (
|
||||
@@ -199,12 +245,40 @@ function AppShellInner({ children }: AppShellProps) {
|
||||
<AIChatPanel isHomePage actionsRef={chatActionsRef} onHasMessagesChange={setHomeChatHasMessages} />
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
<ContextualChatProvider>
|
||||
{/* MainArea wraps EVERYTHING (header + content) so the contextual
|
||||
sidebar, when open, spans the full SidebarInset height. The
|
||||
left ResizablePanel contains the header + scrollable body;
|
||||
the right panel is the sidebar.
|
||||
The inner overflow-hidden div scopes sticky elements (e.g.
|
||||
ProjectTabBar) below the header without sliding behind it. */}
|
||||
<MainArea>
|
||||
<div className="flex flex-col h-full min-w-0">
|
||||
{showHeader && (
|
||||
<header className="flex h-14 shrink-0 items-center gap-2">
|
||||
<div className="flex flex-1 items-center gap-2 px-3">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className={`data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:h-4${leftExtras ? '' : ' mr-2'}`} />
|
||||
{leftExtras ?? (
|
||||
<h4 className="text-sm font-medium text-foreground">{pageLabel}</h4>
|
||||
)}
|
||||
{headerExtras}
|
||||
<div className="flex-1" />
|
||||
{rightExtras}
|
||||
<AdiuvaTriggerButton />
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</MainArea>
|
||||
</ContextualChatProvider>
|
||||
)}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
|
||||
<FloatingChatPortal />
|
||||
</LayoutGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,13 +21,13 @@ import { type ProjectGroup } from '@/components/timeline/ProjectTimelineBox';
|
||||
import { TimelineGanttView } from '@/components/timeline/TimelineGanttView';
|
||||
import { AddEventDialog } from '@/components/timeline/AddEventDialog';
|
||||
import { EditEventDialog } from '@/components/timeline/EditEventDialog';
|
||||
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
import { useTimelineHistory } from '@/hooks/useTimelineHistory';
|
||||
import type { EventSnapshot } from '@/components/timeline/history-types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ProjectTabBar, SECTIONS, type SectionId } from './ProjectTabBar';
|
||||
import { FolderChip } from './folder/FolderChip';
|
||||
import { FilesSection } from './folder/FilesSection';
|
||||
import { useContextualScope } from '@/hooks/useContextualScope';
|
||||
|
||||
type ProjectDetailProps = {
|
||||
projectId: string;
|
||||
@@ -59,7 +59,6 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
|
||||
const didInitialScroll = useRef(false);
|
||||
|
||||
const { registerSection, unregisterSection } = useFloatingChat();
|
||||
const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
|
||||
|
||||
const { data: scanStatus } = trpc.projectFolders.getStatus.useQuery(
|
||||
@@ -87,18 +86,6 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
const clearHistoryRef = useRef(clearHistory);
|
||||
clearHistoryRef.current = clearHistory;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !project) return;
|
||||
registerSection({ id: 'project-summary', label: 'Project Summary', ref: summaryRef, projectId });
|
||||
registerSection({ id: 'project-tasks', label: 'Tasks', ref: tasksRef, projectId });
|
||||
registerSection({ id: 'project-notes', label: 'Notes', ref: notesRef, projectId });
|
||||
return () => {
|
||||
unregisterSection('project-summary');
|
||||
unregisterSection('project-tasks');
|
||||
unregisterSection('project-notes');
|
||||
};
|
||||
}, [projectId, isLoading, project, registerSection, unregisterSection]);
|
||||
|
||||
// Compact hero on scroll. scrollRef is the definitive scroll container
|
||||
// (flex-1 min-h-0 overflow-y-auto inside a flex-col parent with min-h-0
|
||||
// at every ancestor, so h-full never resolves to content-height).
|
||||
@@ -158,6 +145,20 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
const { data: tasksList } = trpc.tasks.list.useQuery({ projectId });
|
||||
const { data: eventsList } = trpc.timelineEvents.list.useQuery({ projectId });
|
||||
|
||||
useContextualScope({
|
||||
page: 'project',
|
||||
entityType: project ? 'project' : null,
|
||||
entityId: project?.id,
|
||||
entityName: project?.name,
|
||||
counts: project
|
||||
? {
|
||||
tasks: tasksList?.length ?? 0,
|
||||
notes: notesList?.length ?? 0,
|
||||
milestones: (eventsList ?? []).filter((e) => e.type === 'milestone').length,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const breadcrumbPath = useMemo(() => {
|
||||
if (!project?.clientId || !clientsList) return [];
|
||||
const clientMap = new Map(clientsList.map((c) => [c.id, c]));
|
||||
@@ -467,9 +468,10 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
{subtitle}
|
||||
</span>
|
||||
</h1>
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 overflow-hidden transition-all duration-200 ease-out',
|
||||
'overflow-hidden transition-all duration-200 ease-out',
|
||||
compact ? 'max-w-0 max-h-0 opacity-0' : 'max-w-xs max-h-8 opacity-100',
|
||||
)}
|
||||
>
|
||||
@@ -502,6 +504,7 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sticky tab bar — owns activeSection state and scroll spy */}
|
||||
<ProjectTabBar
|
||||
@@ -518,7 +521,6 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
<section
|
||||
ref={summaryRef}
|
||||
data-section="overview"
|
||||
data-ai-section="project-summary"
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('projects.overview')}</h1>
|
||||
@@ -578,8 +580,6 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
onEdit={handleEditEvent}
|
||||
onDuplicate={handleDuplicate}
|
||||
onMove={handleMoveEvent}
|
||||
sectionId="project-timeline"
|
||||
sectionLabel="Project Timeline"
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
@@ -600,7 +600,6 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
<section
|
||||
ref={tasksRef}
|
||||
data-section="tasks"
|
||||
data-ai-section="project-tasks"
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('projects.tasks')}</h1>
|
||||
@@ -611,7 +610,6 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
<section
|
||||
ref={notesRef}
|
||||
data-section="notes"
|
||||
data-ai-section="project-notes"
|
||||
className="flex flex-col gap-4 pb-16"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -55,9 +55,7 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { useExpandedClients } from '@/context/ExpandedClientsContext';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
@@ -72,9 +70,11 @@ const NO_CLIENT_KEY = '__no_client__';
|
||||
type ProjectSidebarProps = {
|
||||
selectedProjectId: string | undefined;
|
||||
onSelectProject: (id: string) => void;
|
||||
newProjectOpen: boolean;
|
||||
setNewProjectOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSidebarProps) {
|
||||
export function ProjectSidebar({ selectedProjectId, onSelectProject, newProjectOpen, setNewProjectOpen }: ProjectSidebarProps) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
@@ -96,8 +96,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
const [editCreatingSubClient, setEditCreatingSubClient] = useState(false);
|
||||
const [editNewSubClientName, setEditNewSubClientName] = useState('');
|
||||
|
||||
// New-project dialog state
|
||||
const [newProjectOpen, setNewProjectOpen] = useState(false);
|
||||
// New-project dialog state (open state is lifted to projects.tsx — see newProjectOpen prop)
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
const [newProjectClientId, setNewProjectClientId] = useState<string>(NO_CLIENT_KEY);
|
||||
const [newProjectSubClientId, setNewProjectSubClientId] = useState<string>(NO_CLIENT_KEY);
|
||||
@@ -108,6 +107,19 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
const [creatingSubClient, setCreatingSubClient] = useState(false);
|
||||
const [newSubClientName, setNewSubClientName] = useState('');
|
||||
|
||||
// Reset form fields whenever the dialog opens (whether triggered from header or empty-state button)
|
||||
useEffect(() => {
|
||||
if (newProjectOpen) {
|
||||
setNewProjectName('');
|
||||
setNewProjectClientId(NO_CLIENT_KEY);
|
||||
setNewProjectSubClientId(NO_CLIENT_KEY);
|
||||
setCreatingClient(false);
|
||||
setNewClientName('');
|
||||
setCreatingSubClient(false);
|
||||
setNewSubClientName('');
|
||||
}
|
||||
}, [newProjectOpen]);
|
||||
|
||||
const { data: projectList = [] } = trpc.projects.list.useQuery(
|
||||
{ includeArchived: showArchived },
|
||||
);
|
||||
@@ -265,13 +277,6 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
}
|
||||
|
||||
function handleOpenNewProject() {
|
||||
setNewProjectName('');
|
||||
setNewProjectClientId(NO_CLIENT_KEY);
|
||||
setNewProjectSubClientId(NO_CLIENT_KEY);
|
||||
setCreatingClient(false);
|
||||
setNewClientName('');
|
||||
setCreatingSubClient(false);
|
||||
setNewSubClientName('');
|
||||
setNewProjectOpen(true);
|
||||
}
|
||||
|
||||
@@ -410,25 +415,8 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 border-r border-border w-60 shrink-0">
|
||||
{/* Header */}
|
||||
<div className="flex h-14 items-center gap-2 px-3 shrink-0">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className="data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px mr-2 data-[orientation=vertical]:h-4" />
|
||||
<h4 className="text-sm font-medium text-foreground flex-1">{t('projects.projects')}</h4>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={handleOpenNewProject}
|
||||
disabled={createMutation.isPending}
|
||||
aria-label={t('projects.newProject')}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-3 pb-2 shrink-0">
|
||||
<div className="px-3 pt-2 pb-2 shrink-0">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
|
||||
@@ -9,7 +9,7 @@ export type SectionId = typeof SECTIONS[number];
|
||||
interface ProjectTabBarProps {
|
||||
sectionRefs: Record<SectionId, RefObject<HTMLDivElement | null>>;
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
heroRef: RefObject<HTMLDivElement | null>;
|
||||
heroRef?: RefObject<HTMLDivElement | null>;
|
||||
initialTab?: string;
|
||||
}
|
||||
|
||||
@@ -20,10 +20,23 @@ export function ProjectTabBar({ sectionRefs, scrollRef, heroRef, initialTab }: P
|
||||
(SECTIONS.includes(initialTab as SectionId) ? initialTab : 'overview') as SectionId,
|
||||
);
|
||||
|
||||
// Live hero height — kept in state so both the sticky top offset and the
|
||||
// IntersectionObserver rootMargin update automatically when the hero resizes
|
||||
// (compact ↔ expanded transition) or when it first appears after data loads.
|
||||
const [heroH, setHeroH] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = heroRef?.current;
|
||||
if (!el) return;
|
||||
const measure = () => setHeroH(el.getBoundingClientRect().height);
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [heroRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = scrollRef.current;
|
||||
if (!root) return;
|
||||
const heroH = heroRef.current?.getBoundingClientRect().height ?? 88;
|
||||
const tabBarH = 41;
|
||||
const visible = new Map<SectionId, IntersectionObserverEntry>();
|
||||
const observer = new IntersectionObserver(
|
||||
@@ -56,7 +69,7 @@ export function ProjectTabBar({ sectionRefs, scrollRef, heroRef, initialTab }: P
|
||||
if (ref.current) observer.observe(ref.current);
|
||||
}
|
||||
return () => observer.disconnect();
|
||||
}, [sectionRefs, scrollRef, heroRef]);
|
||||
}, [sectionRefs, scrollRef, heroH]);
|
||||
|
||||
const scrollToSection = useCallback((id: SectionId) => {
|
||||
const el = scrollRef.current;
|
||||
@@ -66,17 +79,18 @@ export function ProjectTabBar({ sectionRefs, scrollRef, heroRef, initialTab }: P
|
||||
} else {
|
||||
const ref = sectionRefs[id];
|
||||
if (!ref?.current) return;
|
||||
const heroH = heroRef.current?.getBoundingClientRect().height ?? 88;
|
||||
const currentHeroH = heroRef?.current?.getBoundingClientRect().height ?? heroH;
|
||||
const sectionTop = ref.current.getBoundingClientRect().top;
|
||||
const containerTop = el.getBoundingClientRect().top;
|
||||
const top = el.scrollTop + sectionTop - containerTop - heroH - 41;
|
||||
const top = el.scrollTop + sectionTop - containerTop - currentHeroH - 41;
|
||||
el.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
|
||||
}
|
||||
void navigate({
|
||||
search: (prev: Record<string, string | undefined>) => ({ ...prev, tab: id }),
|
||||
replace: true,
|
||||
});
|
||||
}, [sectionRefs, scrollRef, heroRef, navigate]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sectionRefs, scrollRef, heroRef, heroH, navigate]);
|
||||
|
||||
const TAB_LABELS: Record<SectionId, string> = {
|
||||
overview: t('projects.overview'),
|
||||
@@ -89,7 +103,7 @@ export function ProjectTabBar({ sectionRefs, scrollRef, heroRef, initialTab }: P
|
||||
return (
|
||||
<nav
|
||||
className="sticky z-20 backdrop-blur-md border-b border-border/40"
|
||||
style={{ top: 'var(--hero-h)' }}
|
||||
style={{ top: heroH }}
|
||||
>
|
||||
<div className="mx-auto max-w-6xl px-8 flex gap-0">
|
||||
{SECTIONS.map((id) => (
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
279
src/renderer/context/ContextualChatContext.tsx
Normal file
279
src/renderer/context/ContextualChatContext.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import type { ChatMessage } from '@/hooks/useAIChat';
|
||||
|
||||
export interface ContextualScope {
|
||||
page: 'timeline' | 'tasks' | 'projects-list' | 'project' | 'note';
|
||||
entityType?: 'project' | 'note' | null;
|
||||
entityId?: string;
|
||||
entityName?: string;
|
||||
projectId?: string | null;
|
||||
counts?: { tasks?: number; notes?: number; milestones?: number };
|
||||
charCount?: number;
|
||||
filters?: unknown;
|
||||
}
|
||||
|
||||
interface ContextualChatState {
|
||||
open: boolean;
|
||||
size: number;
|
||||
sessionId: string | null;
|
||||
scope: ContextualScope | null;
|
||||
messages: ChatMessage[];
|
||||
isStreaming: boolean;
|
||||
streamingContent: string;
|
||||
toggle: () => void;
|
||||
close: () => void;
|
||||
newChat: () => Promise<void>;
|
||||
setSize: (s: number) => void;
|
||||
setScope: (s: ContextualScope) => void;
|
||||
send: (text: string) => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<ContextualChatState | null>(null);
|
||||
|
||||
const SESSION_KEY = 'chat.contextual.lastSessionId';
|
||||
const SIZE_KEY = 'chat.sidebar.size';
|
||||
const OPEN_KEY = 'chat.contextual.open';
|
||||
|
||||
const SIZE_MIN = 22;
|
||||
const SIZE_MAX = 60;
|
||||
const SIZE_DEFAULT = 38;
|
||||
|
||||
function clamp(n: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, n));
|
||||
}
|
||||
|
||||
function readNumber(k: string, fallback: number): number {
|
||||
if (typeof window === 'undefined') return fallback;
|
||||
const v = window.localStorage.getItem(k);
|
||||
if (!v) return fallback;
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
// Defensive: clamp stale or out-of-range persisted values.
|
||||
return clamp(n, SIZE_MIN, SIZE_MAX);
|
||||
}
|
||||
|
||||
export function ContextualChatProvider({ children }: { children: React.ReactNode }) {
|
||||
const [open, setOpen] = useState<boolean>(() =>
|
||||
typeof window !== 'undefined' && window.localStorage.getItem(OPEN_KEY) === '1',
|
||||
);
|
||||
const [size, setSizeState] = useState<number>(() => readNumber(SIZE_KEY, SIZE_DEFAULT));
|
||||
const [sessionId, setSessionId] = useState<string | null>(() =>
|
||||
typeof window !== 'undefined' ? window.localStorage.getItem(SESSION_KEY) : null,
|
||||
);
|
||||
const [scope, setScopeState] = useState<ContextualScope | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
const streamRef = useRef('');
|
||||
const activeUnsubRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const createSession = trpc.aiChat.createSession.useMutation();
|
||||
const appendMessage = trpc.aiChat.appendMessage.useMutation();
|
||||
const chatMutation = trpc.ai.chat.useMutation();
|
||||
|
||||
// Hydrate or create session on mount. One-shot effect.
|
||||
const hydratedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (hydratedRef.current) return;
|
||||
hydratedRef.current = true;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
if (!sessionId) {
|
||||
const { id } = await createSession.mutateAsync({ channel: 'contextual' });
|
||||
if (cancelled) return;
|
||||
window.localStorage.setItem(SESSION_KEY, id);
|
||||
setSessionId(id);
|
||||
} else {
|
||||
const res = await utils.aiChat.getSession.fetch({ id: sessionId });
|
||||
if (cancelled) return;
|
||||
if (!res) {
|
||||
const { id } = await createSession.mutateAsync({ channel: 'contextual' });
|
||||
if (cancelled) return;
|
||||
window.localStorage.setItem(SESSION_KEY, id);
|
||||
setSessionId(id);
|
||||
} else if (res.messages) {
|
||||
setMessages(
|
||||
res.messages
|
||||
.filter((m) => m.role !== 'system')
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: m.content,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const setSize = useCallback((s: number) => {
|
||||
const clamped = clamp(s, SIZE_MIN, SIZE_MAX);
|
||||
setSizeState(clamped);
|
||||
window.localStorage.setItem(SIZE_KEY, String(clamped));
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setOpen((o) => {
|
||||
const next = !o;
|
||||
window.localStorage.setItem(OPEN_KEY, next ? '1' : '0');
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setOpen(false);
|
||||
window.localStorage.setItem(OPEN_KEY, '0');
|
||||
}, []);
|
||||
|
||||
const newChat = useCallback(async () => {
|
||||
const { id } = await createSession.mutateAsync({ channel: 'contextual' });
|
||||
window.localStorage.setItem(SESSION_KEY, id);
|
||||
setSessionId(id);
|
||||
setMessages([]);
|
||||
}, [createSession]);
|
||||
|
||||
const lastScopeKeyRef = useRef<string>('');
|
||||
const setScope = useCallback(
|
||||
(s: ContextualScope) => {
|
||||
const key = JSON.stringify(s);
|
||||
if (key === lastScopeKeyRef.current) return;
|
||||
lastScopeKeyRef.current = key;
|
||||
setScopeState(s);
|
||||
if (sessionId && (window as any).electronAI?.sendContextualScopeUpdate) {
|
||||
// Best-effort fire — exposed by preload in M4.7.
|
||||
(window as any).electronAI.sendContextualScopeUpdate({ sessionId, scope: s });
|
||||
}
|
||||
},
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
const send = useCallback(
|
||||
(text: string) => {
|
||||
if (!sessionId || !scope) return;
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || isStreaming) return;
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: trimmed,
|
||||
};
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
appendMessage.mutate({
|
||||
sessionId,
|
||||
role: 'user',
|
||||
content: trimmed,
|
||||
scope: JSON.stringify(scope),
|
||||
});
|
||||
|
||||
const requestId = crypto.randomUUID();
|
||||
setIsStreaming(true);
|
||||
setStreamingContent('');
|
||||
streamRef.current = '';
|
||||
|
||||
const unsub = window.electronAI.onStreamEvent((event) => {
|
||||
if (event.requestId !== requestId) return;
|
||||
switch (event.type) {
|
||||
case 'stream_text':
|
||||
streamRef.current += event.chunk;
|
||||
setStreamingContent(streamRef.current);
|
||||
break;
|
||||
case 'stream_end': {
|
||||
const final = streamRef.current;
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: final },
|
||||
]);
|
||||
appendMessage.mutate({
|
||||
sessionId,
|
||||
role: 'assistant',
|
||||
content: final,
|
||||
scope: JSON.stringify(scope),
|
||||
});
|
||||
setStreamingContent('');
|
||||
streamRef.current = '';
|
||||
setIsStreaming(false);
|
||||
unsub();
|
||||
activeUnsubRef.current = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
activeUnsubRef.current = unsub;
|
||||
|
||||
chatMutation.mutate(
|
||||
{
|
||||
requestId,
|
||||
message: trimmed,
|
||||
conversationHistory: messages.slice(-20).map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
sessionId,
|
||||
mode: 'contextual',
|
||||
scope,
|
||||
},
|
||||
{
|
||||
onError: (err) => {
|
||||
unsub();
|
||||
activeUnsubRef.current = null;
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: err.message || 'An unexpected error occurred.',
|
||||
error: true,
|
||||
},
|
||||
]);
|
||||
setStreamingContent('');
|
||||
streamRef.current = '';
|
||||
setIsStreaming(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[sessionId, scope, isStreaming, messages, appendMessage, chatMutation],
|
||||
);
|
||||
|
||||
// Unmount cleanup: unsubscribe any in-flight stream listener.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
activeUnsubRef.current?.();
|
||||
activeUnsubRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ContextualChatState>(
|
||||
() => ({
|
||||
open,
|
||||
size,
|
||||
sessionId,
|
||||
scope,
|
||||
messages,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
toggle,
|
||||
close,
|
||||
newChat,
|
||||
setSize,
|
||||
setScope,
|
||||
send,
|
||||
}),
|
||||
[open, size, sessionId, scope, messages, isStreaming, streamingContent, toggle, close, newChat, setSize, setScope, send],
|
||||
);
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useContextualChat() {
|
||||
const v = useContext(Ctx);
|
||||
if (!v) throw new Error('useContextualChat must be used within ContextualChatProvider');
|
||||
return v;
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useState,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
|
||||
// ---------- Types ----------
|
||||
|
||||
interface AISection {
|
||||
id: string; // e.g. "project-tasks", "tasks-list", "timeline-chart"
|
||||
label: string; // Human-readable, e.g. "Tasks", "Project Timeline"
|
||||
ref: RefObject<HTMLElement | null>;
|
||||
projectId?: string; // If section is project-scoped
|
||||
anchorMode?: 'top-right' | 'right-margin'; // default: 'top-right'
|
||||
}
|
||||
|
||||
interface SectionOpenOpts {
|
||||
clickY?: number; // For right-margin mode: Y-coordinate of the double-click
|
||||
}
|
||||
|
||||
interface FloatingChatState {
|
||||
isOpen: boolean;
|
||||
activeSectionId: string | null;
|
||||
position: { x: number; y: number; width: number };
|
||||
morphTargetId: string | null;
|
||||
projectId?: string;
|
||||
pendingSection?: { sectionId: string; clickY?: number }; // For cross-page navigation
|
||||
}
|
||||
|
||||
interface FloatingChatContextValue {
|
||||
// State
|
||||
state: FloatingChatState;
|
||||
sections: Map<string, AISection>;
|
||||
|
||||
// Section registry
|
||||
registerSection: (section: AISection) => void;
|
||||
unregisterSection: (id: string) => void;
|
||||
|
||||
// Actions
|
||||
openAtSection: (sectionId: string, opts?: SectionOpenOpts) => void;
|
||||
moveToSection: (sectionId: string, opts?: SectionOpenOpts) => void;
|
||||
close: () => void;
|
||||
setMorphTarget: (id: string | null) => void;
|
||||
updatePosition: (pos: { x: number; y: number; width: number }) => void;
|
||||
setPendingSection: (pending: { sectionId: string; clickY?: number } | undefined) => void;
|
||||
}
|
||||
|
||||
// ---------- Constants ----------
|
||||
|
||||
/** Dynamic chat width: 35% of viewport, clamped between 320px and 520px. */
|
||||
export function getChatWidth(): number {
|
||||
return Math.min(630, Math.max(320, Math.round(window.innerWidth * 0.35)));
|
||||
}
|
||||
|
||||
export const CHAT_HEIGHT = 420;
|
||||
export const PADDING = 16;
|
||||
|
||||
// ---------- Position computation ----------
|
||||
|
||||
function clampPosition(x: number, y: number): { x: number; y: number } {
|
||||
const w = getChatWidth();
|
||||
return {
|
||||
x: Math.max(PADDING, Math.min(x, window.innerWidth - w - PADDING)),
|
||||
y: Math.max(PADDING, Math.min(y, window.innerHeight - CHAT_HEIGHT - PADDING)),
|
||||
};
|
||||
}
|
||||
|
||||
function computeAnchorPosition(
|
||||
section: AISection,
|
||||
opts?: SectionOpenOpts,
|
||||
): { x: number; y: number; width: number } {
|
||||
const el = section.ref.current;
|
||||
const w = getChatWidth();
|
||||
if (!el) return { x: PADDING, y: PADDING, width: w };
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const mode = section.anchorMode ?? 'top-right';
|
||||
|
||||
if (mode === 'right-margin') {
|
||||
// Position to the right of the section at the click Y-coordinate
|
||||
const rawX = rect.right + PADDING;
|
||||
const rawY = opts?.clickY ?? rect.top + PADDING;
|
||||
const { x, y } = clampPosition(rawX, rawY);
|
||||
return { x, y, width: w };
|
||||
}
|
||||
|
||||
// Default: top-right of section
|
||||
const rawX = rect.right - w - PADDING;
|
||||
const rawY = rect.top + PADDING;
|
||||
const { x, y } = clampPosition(rawX, rawY);
|
||||
return { x, y, width: w };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dual-anchor recomputation for scroll tracking.
|
||||
* Returns null when the section is fully off-screen (freeze at last position).
|
||||
*/
|
||||
export function computeDualAnchor(
|
||||
section: AISection,
|
||||
): { x: number; y: number; width: number } | null {
|
||||
const el = section.ref.current;
|
||||
if (!el) return null;
|
||||
|
||||
// Skip scroll tracking for right-margin mode (stays at fixed clickY)
|
||||
if (section.anchorMode === 'right-margin') return null;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const w = getChatWidth();
|
||||
|
||||
// Fully off-screen — freeze
|
||||
if (rect.bottom < 0 || rect.top > window.innerHeight) return null;
|
||||
|
||||
// Primary anchor: top-right (when section top is visible)
|
||||
if (rect.top >= PADDING) {
|
||||
const { x, y } = clampPosition(
|
||||
rect.right - w - PADDING,
|
||||
rect.top + PADDING,
|
||||
);
|
||||
return { x, y, width: w };
|
||||
}
|
||||
|
||||
// Fallback anchor: bottom-right (when section top scrolled off)
|
||||
if (rect.bottom > CHAT_HEIGHT) {
|
||||
const { x, y } = clampPosition(
|
||||
rect.right - w - PADDING,
|
||||
rect.bottom - CHAT_HEIGHT - PADDING,
|
||||
);
|
||||
return { x, y, width: w };
|
||||
}
|
||||
|
||||
// Section visible but too small for fallback — clamp to top
|
||||
const { x, y } = clampPosition(
|
||||
rect.right - w - PADDING,
|
||||
PADDING,
|
||||
);
|
||||
return { x, y, width: w };
|
||||
}
|
||||
|
||||
// ---------- Context ----------
|
||||
|
||||
const FloatingChatCtx = createContext<FloatingChatContextValue | null>(null);
|
||||
|
||||
export function useFloatingChat(): FloatingChatContextValue {
|
||||
const ctx = useContext(FloatingChatCtx);
|
||||
if (!ctx)
|
||||
throw new Error('useFloatingChat must be used within FloatingChatProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
|
||||
// ---------- Provider ----------
|
||||
|
||||
export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
||||
const sectionsRef = useRef<Map<string, AISection>>(new Map());
|
||||
const [sections, setSections] = useState<Map<string, AISection>>(new Map());
|
||||
const [state, setState] = useState<FloatingChatState>({
|
||||
isOpen: false,
|
||||
activeSectionId: null,
|
||||
position: { x: 0, y: 0, width: getChatWidth() },
|
||||
morphTargetId: null,
|
||||
});
|
||||
|
||||
const registerSection = useCallback((section: AISection) => {
|
||||
sectionsRef.current.set(section.id, section);
|
||||
setSections(new Map(sectionsRef.current));
|
||||
|
||||
// Check if there's a pending section to open after cross-page navigation
|
||||
setState((prev) => {
|
||||
if (prev.pendingSection && prev.pendingSection.sectionId === section.id) {
|
||||
const position = computeAnchorPosition(section, { clickY: prev.pendingSection.clickY });
|
||||
return {
|
||||
...prev,
|
||||
isOpen: true,
|
||||
activeSectionId: section.id,
|
||||
position,
|
||||
morphTargetId: null,
|
||||
projectId: section.projectId,
|
||||
pendingSection: undefined,
|
||||
};
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const unregisterSection = useCallback((id: string) => {
|
||||
sectionsRef.current.delete(id);
|
||||
setSections(new Map(sectionsRef.current));
|
||||
}, []);
|
||||
|
||||
const openAtSection = useCallback((sectionId: string, opts?: SectionOpenOpts) => {
|
||||
const section = sectionsRef.current.get(sectionId);
|
||||
if (!section) return;
|
||||
|
||||
const position = computeAnchorPosition(section, opts);
|
||||
|
||||
setState({
|
||||
isOpen: true,
|
||||
activeSectionId: sectionId,
|
||||
position,
|
||||
morphTargetId: null,
|
||||
projectId: section.projectId,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const moveToSection = useCallback((sectionId: string, opts?: SectionOpenOpts) => {
|
||||
const section = sectionsRef.current.get(sectionId);
|
||||
if (!section) return;
|
||||
|
||||
const position = computeAnchorPosition(section, opts);
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
activeSectionId: sectionId,
|
||||
position,
|
||||
projectId: section.projectId,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isOpen: false,
|
||||
activeSectionId: null,
|
||||
morphTargetId: null,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setMorphTarget = useCallback((id: string | null) => {
|
||||
setState((prev) => ({ ...prev, morphTargetId: id }));
|
||||
}, []);
|
||||
|
||||
const updatePosition = useCallback((pos: { x: number; y: number; width: number }) => {
|
||||
setState((prev) => ({ ...prev, position: pos }));
|
||||
}, []);
|
||||
|
||||
const setPendingSection = useCallback((pending: { sectionId: string; clickY?: number } | undefined) => {
|
||||
setState((prev) => ({ ...prev, pendingSection: pending }));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FloatingChatCtx.Provider
|
||||
value={{
|
||||
state,
|
||||
sections,
|
||||
registerSection,
|
||||
unregisterSection,
|
||||
openAtSection,
|
||||
moveToSection,
|
||||
close,
|
||||
setMorphTarget,
|
||||
updatePosition,
|
||||
setPendingSection,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</FloatingChatCtx.Provider>
|
||||
);
|
||||
}
|
||||
40
src/renderer/context/HeaderContext.tsx
Normal file
40
src/renderer/context/HeaderContext.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||
|
||||
interface HeaderContextValue {
|
||||
label: string | null;
|
||||
extras: ReactNode;
|
||||
/** Replaces the page-label slot. When set, no h4 is rendered. */
|
||||
leftExtras: ReactNode;
|
||||
/** Rendered between the flex-1 spacer and the AdiuvaTriggerButton. */
|
||||
rightExtras: ReactNode;
|
||||
setLabel: (label: string | null) => void;
|
||||
setExtras: (extras: ReactNode) => void;
|
||||
setLeftExtras: (extras: ReactNode) => void;
|
||||
setRightExtras: (extras: ReactNode) => void;
|
||||
}
|
||||
|
||||
const HeaderContext = createContext<HeaderContextValue | null>(null);
|
||||
|
||||
export function HeaderProvider({ children }: { children: ReactNode }) {
|
||||
const [label, setLabelState] = useState<string | null>(null);
|
||||
const [extras, setExtrasState] = useState<ReactNode>(null);
|
||||
const [leftExtras, setLeftExtrasState] = useState<ReactNode>(null);
|
||||
const [rightExtras, setRightExtrasState] = useState<ReactNode>(null);
|
||||
|
||||
const setLabel = useCallback((l: string | null) => setLabelState(l), []);
|
||||
const setExtras = useCallback((e: ReactNode) => setExtrasState(e), []);
|
||||
const setLeftExtras = useCallback((e: ReactNode) => setLeftExtrasState(e), []);
|
||||
const setRightExtras = useCallback((e: ReactNode) => setRightExtrasState(e), []);
|
||||
|
||||
return (
|
||||
<HeaderContext.Provider value={{ label, extras, leftExtras, rightExtras, setLabel, setExtras, setLeftExtras, setRightExtras }}>
|
||||
{children}
|
||||
</HeaderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useHeader() {
|
||||
const ctx = useContext(HeaderContext);
|
||||
if (!ctx) throw new Error('useHeader must be used inside HeaderProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -365,3 +365,78 @@ body {
|
||||
--crepe-shadow-1: 0px 1px 2px 0px rgba(255, 255, 255, 0.3), 0px 1px 3px 1px rgba(255, 255, 255, 0.15);
|
||||
--crepe-shadow-2: 0px 1px 2px 0px rgba(255, 255, 255, 0.3), 0px 2px 6px 2px rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Adiuva trigger button + compass icon
|
||||
* --------------------------------------------------------------------------- */
|
||||
|
||||
.adiuva-btn {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* Light mode: pure white pops on pinkish canvas (#f4edf3). */
|
||||
background: #ffffff;
|
||||
border: 1px solid #c8c3cd;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: box-shadow .25s ease, background .2s ease, border-color .2s ease;
|
||||
/* Centered ambient shadow, not bottom-weighted. */
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, .02),
|
||||
0 2px 8px -2px rgba(0, 0, 0, .08),
|
||||
0 6px 16px -4px rgba(0, 0, 0, .06);
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
.adiuva-btn:hover {
|
||||
background: #ffffff;
|
||||
border-color: color-mix(in srgb, #c8c3cd 50%, #fbc881 50%);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(251, 200, 129, .25),
|
||||
0 2px 10px -2px rgba(0, 0, 0, .10),
|
||||
0 8px 22px -4px rgba(251, 200, 129, .22);
|
||||
}
|
||||
.adiuva-btn:active { transform: scale(.97); }
|
||||
.adiuva-btn.sm { width: 40px; height: 40px; border-radius: 12px; }
|
||||
|
||||
/* Dark mode: surface needs to lift off near-black canvas (#0c0c0c). */
|
||||
.dark .adiuva-btn {
|
||||
background: #1f1f22;
|
||||
border-color: rgba(255, 255, 255, .08);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, .05),
|
||||
0 0 0 1px rgba(0, 0, 0, .35),
|
||||
0 4px 14px -2px rgba(0, 0, 0, .55),
|
||||
0 10px 24px -6px rgba(0, 0, 0, .45);
|
||||
}
|
||||
.dark .adiuva-btn:hover {
|
||||
background: #26262a;
|
||||
border-color: rgba(251, 200, 129, .18);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, .06),
|
||||
0 0 0 1px rgba(251, 200, 129, .12),
|
||||
0 4px 18px -2px rgba(0, 0, 0, .6),
|
||||
0 12px 28px -8px rgba(251, 200, 129, .25);
|
||||
}
|
||||
|
||||
/* The asset SVG already animates internally — img tag uses its own keyframes.
|
||||
These external keyframes remain only for the older inline-SVG path (kept
|
||||
for back-compat if any consumer still uses <AdiuvaIcon> inline). */
|
||||
.adiuva-needle-g {
|
||||
transform-origin: 32px 32px;
|
||||
animation: adiuva-compass-settle 6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes adiuva-compass-settle {
|
||||
0% { transform: rotate(0deg); }
|
||||
20% { transform: rotate(4deg); }
|
||||
50% { transform: rotate(-3deg); }
|
||||
80% { transform: rotate(2deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.adiuva-needle-g { animation: none; }
|
||||
.adiuva-mark-img { animation: none; }
|
||||
}
|
||||
|
||||
@@ -1,33 +1,16 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
|
||||
export type FloatingDomainSignal =
|
||||
| 'tasks'
|
||||
| 'notes'
|
||||
| 'timelines'
|
||||
| 'projects'
|
||||
| {
|
||||
type: 'task' | 'timeline' | 'project' | 'note' | 'node';
|
||||
id?: string | null;
|
||||
section?: 'task' | 'timeline' | 'note' | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Renderer-only context describing where the user is in the UI.
|
||||
* Retained for call-site compatibility; mode/scope fields support v3 routing.
|
||||
*/
|
||||
export interface UIChatContext {
|
||||
type: 'global' | 'project' | 'floating';
|
||||
type: 'global' | 'project';
|
||||
projectId?: string;
|
||||
/** For floating mode — the entity scope to pass to the backend. */
|
||||
scope?: {
|
||||
type: 'task' | 'project' | 'note' | 'timeline';
|
||||
id?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
@@ -47,10 +30,6 @@ interface UseAIChatReturn {
|
||||
cacheKey: string;
|
||||
}
|
||||
|
||||
interface UseAIChatOptions {
|
||||
onDomainSignal?: (domain: FloatingDomainSignal) => void;
|
||||
}
|
||||
|
||||
interface CachedChatState {
|
||||
messages: ChatMessage[];
|
||||
/** Written by ChatInputBox; read on mount to restore draft. Not written by this hook. */
|
||||
@@ -62,11 +41,7 @@ const chatSessionCache = new Map<string, CachedChatState>();
|
||||
|
||||
function getContextCacheKey(ctx: UIChatContext): string {
|
||||
if (ctx.type === 'global') return 'global';
|
||||
if (ctx.type === 'project') return `project:${ctx.projectId ?? ''}`;
|
||||
|
||||
// Floating chat should keep a single continuous session while the panel is open,
|
||||
// even when route/section context changes due floating-domain navigation.
|
||||
return 'floating';
|
||||
return `project:${ctx.projectId ?? ''}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -98,7 +73,7 @@ const TABLE_TO_ENTITY: Record<string, 'task' | 'project' | 'note' | 'timeline'>
|
||||
timelineEvents: 'timeline',
|
||||
};
|
||||
|
||||
function parseMutationsToEntityTags(mutations: unknown[] | undefined): string {
|
||||
export function parseMutationsToEntityTags(mutations: unknown[] | undefined): string {
|
||||
if (!Array.isArray(mutations)) return '';
|
||||
const tags: string[] = [];
|
||||
for (const m of mutations) {
|
||||
@@ -121,10 +96,10 @@ function parseMutationsToEntityTags(mutations: unknown[] | undefined): string {
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOptions): UseAIChatReturn {
|
||||
export function useAIChat(defaultContext: UIChatContext): UseAIChatReturn {
|
||||
const contextCacheKey = useMemo(
|
||||
() => getContextCacheKey(defaultContext),
|
||||
[defaultContext.type, defaultContext.projectId, defaultContext.scope?.type, defaultContext.scope?.id],
|
||||
[defaultContext.type, defaultContext.projectId],
|
||||
);
|
||||
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(
|
||||
@@ -148,9 +123,6 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
|
||||
messagesRef.current = messages;
|
||||
const sessionIdRef = useRef(sessionId);
|
||||
sessionIdRef.current = sessionId;
|
||||
const onDomainSignalRef = useRef(options?.onDomainSignal);
|
||||
onDomainSignalRef.current = options?.onDomainSignal;
|
||||
|
||||
// Keep local state aligned when the chat context changes in-place.
|
||||
useEffect(() => {
|
||||
const cached = chatSessionCache.get(contextCacheKey);
|
||||
@@ -234,9 +206,6 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
|
||||
break;
|
||||
}
|
||||
|
||||
case 'floating_domain':
|
||||
onDomainSignalRef.current?.(event.domain);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -246,17 +215,12 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
const isFloating = ctx.type === 'floating';
|
||||
|
||||
chatMutationRef.current.mutate(
|
||||
{
|
||||
requestId,
|
||||
message: trimmed,
|
||||
conversationHistory,
|
||||
sessionId: sessionIdRef.current,
|
||||
...(isFloating && ctx.scope
|
||||
? { mode: 'floating' as const, scope: ctx.scope }
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
|
||||
97
src/renderer/hooks/useChatStream.ts
Normal file
97
src/renderer/hooks/useChatStream.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import type { ChatMessage } from './useAIChat';
|
||||
import { parseMutationsToEntityTags } from './useAIChat';
|
||||
|
||||
export type ChatStreamMode =
|
||||
| { kind: 'home' }
|
||||
| { kind: 'project'; projectId?: string }
|
||||
| { kind: 'contextual'; scope: unknown };
|
||||
|
||||
export interface UseChatStreamArgs {
|
||||
sessionId: string;
|
||||
/** Called when the full assistant turn has been assembled. */
|
||||
onAssistantMessage: (msg: ChatMessage) => void;
|
||||
/** Called when the request fails. */
|
||||
onError: (msg: ChatMessage) => void;
|
||||
}
|
||||
|
||||
export function useChatStream({
|
||||
sessionId,
|
||||
onAssistantMessage,
|
||||
onError,
|
||||
}: UseChatStreamArgs) {
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
const ref = useRef('');
|
||||
const mutation = trpc.ai.chat.useMutation();
|
||||
const mutationRef = useRef(mutation);
|
||||
mutationRef.current = mutation;
|
||||
const send = useCallback(
|
||||
(args: {
|
||||
message: string;
|
||||
history: { role: 'user' | 'assistant'; content: string }[];
|
||||
mode: ChatStreamMode;
|
||||
}) => {
|
||||
if (isStreaming) return;
|
||||
setIsStreaming(true);
|
||||
setStreamingContent('');
|
||||
ref.current = '';
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
const unsubscribe = window.electronAI.onStreamEvent((event) => {
|
||||
if (event.requestId !== requestId) return;
|
||||
switch (event.type) {
|
||||
case 'stream_start':
|
||||
break;
|
||||
case 'stream_text':
|
||||
ref.current += event.chunk;
|
||||
setStreamingContent(ref.current);
|
||||
break;
|
||||
case 'stream_end': {
|
||||
const mutationTags = parseMutationsToEntityTags(event.mutations);
|
||||
onAssistantMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: ref.current + mutationTags,
|
||||
});
|
||||
setStreamingContent('');
|
||||
ref.current = '';
|
||||
setIsStreaming(false);
|
||||
unsubscribe();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const input: Record<string, unknown> = {
|
||||
requestId,
|
||||
message: args.message,
|
||||
conversationHistory: args.history,
|
||||
sessionId,
|
||||
};
|
||||
if (args.mode.kind === 'contextual') {
|
||||
input.mode = 'contextual';
|
||||
input.scope = args.mode.scope;
|
||||
}
|
||||
|
||||
mutationRef.current.mutate(input as never, {
|
||||
onError: (err) => {
|
||||
unsubscribe();
|
||||
onError({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: err.message || 'An unexpected error occurred.',
|
||||
error: true,
|
||||
});
|
||||
setStreamingContent('');
|
||||
ref.current = '';
|
||||
setIsStreaming(false);
|
||||
},
|
||||
});
|
||||
},
|
||||
[sessionId, onAssistantMessage, onError, isStreaming],
|
||||
);
|
||||
|
||||
return { send, isStreaming, streamingContent };
|
||||
}
|
||||
12
src/renderer/hooks/useContextualScope.ts
Normal file
12
src/renderer/hooks/useContextualScope.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useContextualChat, type ContextualScope } from '@/context/ContextualChatContext';
|
||||
|
||||
export function useContextualScope(scope: ContextualScope) {
|
||||
const { setScope } = useContextualChat();
|
||||
const key = JSON.stringify(scope);
|
||||
useEffect(() => {
|
||||
setScope(scope);
|
||||
// setScope is a stable identity via the provider's useCallback; safe to omit.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
|
||||
// Elements where double-click should NOT trigger the AI popup
|
||||
const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
|
||||
|
||||
export function useDoubleClickAI(): void {
|
||||
const { openAtSection, moveToSection, sections, state } = useFloatingChat();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// Skip interactive elements (preserve text selection behavior)
|
||||
if (INTERACTIVE_TAGS.has(target.tagName)) return;
|
||||
|
||||
// Skip contenteditable elements UNLESS they're inside Milkdown
|
||||
if (target.isContentEditable) {
|
||||
const inMilkdown =
|
||||
target.closest('.milkdown-container') ||
|
||||
target.closest('.crepe-editor');
|
||||
if (!inMilkdown) return;
|
||||
// For Milkdown: only trigger if no text was selected by the double-click
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.toString().trim().length > 0) return;
|
||||
}
|
||||
|
||||
// Walk up DOM to find nearest [data-ai-section]
|
||||
const sectionEl = (target as Element).closest('[data-ai-section]');
|
||||
if (!sectionEl) return;
|
||||
|
||||
const sectionId = sectionEl.getAttribute('data-ai-section');
|
||||
if (!sectionId) return;
|
||||
|
||||
// If popup is already open at THIS section, do nothing
|
||||
if (state.isOpen && state.activeSectionId === sectionId) return;
|
||||
|
||||
// Build opts for right-margin sections
|
||||
const section = sections.get(sectionId);
|
||||
const opts = section?.anchorMode === 'right-margin' ? { clickY: e.clientY } : undefined;
|
||||
|
||||
// If chat is already open at a different section, move (keep conversation)
|
||||
if (state.isOpen) {
|
||||
moveToSection(sectionId, opts);
|
||||
return;
|
||||
}
|
||||
|
||||
openAtSection(sectionId, opts);
|
||||
};
|
||||
|
||||
document.addEventListener('dblclick', handler);
|
||||
return () => document.removeEventListener('dblclick', handler);
|
||||
}, [openAtSection, moveToSection, sections, state.isOpen, state.activeSectionId]);
|
||||
}
|
||||
27
src/renderer/hooks/useHeaderSlot.ts
Normal file
27
src/renderer/hooks/useHeaderSlot.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { useHeader } from '@/context/HeaderContext';
|
||||
|
||||
/**
|
||||
* Publish a dynamic label and/or extra actions into the AppShell header.
|
||||
* Clears them on unmount so the default page label takes over again.
|
||||
*/
|
||||
export function useHeaderSlot({
|
||||
label,
|
||||
extras,
|
||||
}: {
|
||||
label?: string | null;
|
||||
extras?: ReactNode;
|
||||
}) {
|
||||
const { setLabel, setExtras } = useHeader();
|
||||
|
||||
useEffect(() => {
|
||||
if (label !== undefined) setLabel(label ?? null);
|
||||
return () => setLabel(null);
|
||||
}, [label, setLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (extras !== undefined) setExtras(extras ?? null);
|
||||
return () => setExtras(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [extras, setExtras]);
|
||||
}
|
||||
@@ -16,25 +16,13 @@ interface ElectronTRPC {
|
||||
type V3StreamEvent =
|
||||
| { type: 'stream_start'; requestId: string }
|
||||
| { type: 'stream_text'; requestId: string; chunk: string }
|
||||
| { type: 'stream_end'; requestId: string; mutations?: unknown[] }
|
||||
| {
|
||||
type: 'floating_domain';
|
||||
requestId: string;
|
||||
domain:
|
||||
| 'tasks'
|
||||
| 'notes'
|
||||
| 'timelines'
|
||||
| 'projects'
|
||||
| {
|
||||
type: 'task' | 'timeline' | 'project' | 'note' | 'node';
|
||||
id?: string | null;
|
||||
section?: 'task' | 'timeline' | 'note' | null;
|
||||
};
|
||||
};
|
||||
| { type: 'stream_end'; requestId: string; mutations?: unknown[] };
|
||||
|
||||
interface ElectronAI {
|
||||
onStreamEvent: (cb: (data: V3StreamEvent) => void) => () => void;
|
||||
onBriefUpdated: (cb: (content: string) => void) => () => void;
|
||||
/** Exposed by preload in M4.7. Best-effort fire when renderer scope changes. */
|
||||
sendContextualScopeUpdate?: (args: { sessionId: string; scope: unknown }) => void;
|
||||
}
|
||||
|
||||
interface ElectronDialog {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import { ArrowLeft, Trash2, MoreHorizontal, Sparkles } from 'lucide-react';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useContextualScope } from '@/hooks/useContextualScope';
|
||||
import { useFormatPrefs, formatDateTime } from '@/lib/date';
|
||||
import { useHeader } from '@/context/HeaderContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -26,12 +26,75 @@ import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { MilkdownEditor } from '@/components/notes/MilkdownEditor';
|
||||
import { PendingEditBlock } from '@/components/notes/PendingEditBlock';
|
||||
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
|
||||
export const Route = createFileRoute('/notes/$noteId')({
|
||||
component: NoteDetailPage,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable header slot components — defined at module scope so React never
|
||||
// remounts them when NoteDetailPage re-renders. They read mutable refs for
|
||||
// live values (isSaving, callbacks) to avoid triggering HeaderContext updates.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function NoteHeaderLeft({
|
||||
handleBackRef,
|
||||
}: {
|
||||
handleBackRef: React.MutableRefObject<() => void>;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleBackRef.current()}
|
||||
title="Back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteHeaderRight({
|
||||
isSavingRef,
|
||||
setDeleteOpenRef,
|
||||
}: {
|
||||
isSavingRef: React.MutableRefObject<boolean>;
|
||||
setDeleteOpenRef: React.MutableRefObject<(open: boolean) => void>;
|
||||
}) {
|
||||
const [saving, setSaving] = useState(isSavingRef.current);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setSaving(isSavingRef.current);
|
||||
}, 150);
|
||||
return () => clearInterval(id);
|
||||
}, [isSavingRef]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{saving && (
|
||||
<span className="text-muted-foreground text-xs">Saving…</span>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setDeleteOpenRef.current(true)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete note
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteDetailPage() {
|
||||
const { noteId } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -40,19 +103,16 @@ function NoteDetailPage() {
|
||||
const { data: note, isLoading } = trpc.notes.get.useQuery({ id: noteId });
|
||||
const { data: pendingEdits = [] } = trpc.noteEdits.listPending.useQuery({ noteId });
|
||||
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const { registerSection, unregisterSection } = useFloatingChat();
|
||||
const noteProjectId = note?.projectId ?? undefined;
|
||||
useEffect(() => {
|
||||
registerSection({
|
||||
id: 'note-editor',
|
||||
label: 'Note Editor',
|
||||
ref: editorRef,
|
||||
projectId: noteProjectId,
|
||||
anchorMode: 'right-margin',
|
||||
useContextualScope({
|
||||
page: 'note',
|
||||
entityType: note ? 'note' : null,
|
||||
entityId: note?.id,
|
||||
entityName: note?.title,
|
||||
projectId: note?.projectId ?? null,
|
||||
charCount: (note?.content ?? '').length,
|
||||
});
|
||||
return () => unregisterSection('note-editor');
|
||||
}, [noteId, noteProjectId, registerSection, unregisterSection]);
|
||||
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -147,7 +207,7 @@ function NoteDetailPage() {
|
||||
});
|
||||
}, [navigate, note?.projectId]);
|
||||
|
||||
const handleBack = () => {
|
||||
const handleBack = useCallback(() => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
@@ -157,7 +217,35 @@ function NoteDetailPage() {
|
||||
pendingContentRef.current = null;
|
||||
}
|
||||
goBackToProject();
|
||||
}, [updateNote, noteId, goBackToProject]);
|
||||
|
||||
const { setLeftExtras, setRightExtras } = useHeader();
|
||||
|
||||
// Keep mutable refs so the stable header components can read the latest
|
||||
// values without causing a re-publish of the header slots on each render.
|
||||
const isSavingRef = useRef(isSaving);
|
||||
isSavingRef.current = isSaving;
|
||||
|
||||
const setDeleteOpenRef = useRef(setDeleteOpen);
|
||||
setDeleteOpenRef.current = setDeleteOpen;
|
||||
|
||||
const handleBackRef = useRef(handleBack);
|
||||
handleBackRef.current = handleBack;
|
||||
|
||||
useEffect(() => {
|
||||
setLeftExtras(
|
||||
<NoteHeaderLeft handleBackRef={handleBackRef} />,
|
||||
);
|
||||
setRightExtras(
|
||||
<NoteHeaderRight isSavingRef={isSavingRef} setDeleteOpenRef={setDeleteOpenRef} />,
|
||||
);
|
||||
return () => {
|
||||
setLeftExtras(null);
|
||||
setRightExtras(null);
|
||||
};
|
||||
// setLeftExtras / setRightExtras are stable; refs are always current.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [setLeftExtras, setRightExtras]);
|
||||
|
||||
const handleDelete = () => {
|
||||
if (debounceTimerRef.current) {
|
||||
@@ -186,38 +274,8 @@ function NoteDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* Minimal top bar */}
|
||||
<div className="flex h-14 shrink-0 items-center gap-1 px-3">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className="data-[orientation=vertical]:h-4" />
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{isSaving && (
|
||||
<span className="text-muted-foreground text-xs">Saving…</span>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete note
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notion-style content area */}
|
||||
<ScrollArea ref={editorRef} data-ai-section="note-editor" className="flex-1 min-h-0">
|
||||
<ScrollArea ref={editorRef} className="flex-1 min-h-0">
|
||||
<div className="mx-auto max-w-3xl pb-32 pt-14">
|
||||
{/* Title styled to match the project page h1 */}
|
||||
<input
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
import { FolderKanban } from 'lucide-react';
|
||||
import { FolderKanban, Plus } from 'lucide-react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { ProjectSidebar } from '@/components/projects/ProjectSidebar';
|
||||
import { ProjectDetail } from '@/components/projects/ProjectDetail';
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useContextualScope } from '@/hooks/useContextualScope';
|
||||
import { useHeaderSlot } from '@/hooks/useHeaderSlot';
|
||||
|
||||
const searchSchema = z.object({
|
||||
projectId: z.string().optional(),
|
||||
@@ -16,11 +20,45 @@ export const Route = createFileRoute('/projects')({
|
||||
component: ProjectsPage,
|
||||
});
|
||||
|
||||
// Rendered only when no project is selected. Owns the 'projects-list'
|
||||
// scope registration so it unmounts when ProjectDetail takes over,
|
||||
// preventing the parent-effect-fires-last problem where ProjectsPage
|
||||
// would clobber ProjectDetail's 'project' scope.
|
||||
function ProjectsListScope() {
|
||||
useContextualScope({ page: 'projects-list' });
|
||||
return null;
|
||||
}
|
||||
|
||||
function ProjectsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { projectId, tab } = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
// Create-project dialog open state — lifted here so the header button can trigger it
|
||||
const [newProjectOpen, setNewProjectOpen] = useState(false);
|
||||
|
||||
// Push the create-project icon button into the AppShell header (between the page
|
||||
// label and the AI trigger). Always shown on the projects route.
|
||||
// Memoized to keep a stable JSX reference so the useHeaderSlot effect fires once.
|
||||
const createProjectButton = useMemo(
|
||||
() => (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="size-7"
|
||||
onClick={() => setNewProjectOpen(true)}
|
||||
aria-label={t('projects.newProject')}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
),
|
||||
// t is stable; setNewProjectOpen is stable (React guarantees setState is stable).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
useHeaderSlot({ extras: createProjectButton });
|
||||
|
||||
function handleSelectProject(id: string) {
|
||||
void navigate({ search: { projectId: id } });
|
||||
}
|
||||
@@ -30,12 +68,16 @@ function ProjectsPage() {
|
||||
<ProjectSidebar
|
||||
selectedProjectId={projectId}
|
||||
onSelectProject={handleSelectProject}
|
||||
newProjectOpen={newProjectOpen}
|
||||
setNewProjectOpen={setNewProjectOpen}
|
||||
/>
|
||||
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
{projectId ? (
|
||||
<ProjectDetail projectId={projectId} initialTab={tab} />
|
||||
) : (
|
||||
<Empty className="h-full">
|
||||
<>
|
||||
<ProjectsListScope />
|
||||
<Empty className="flex-1">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<FolderKanban />
|
||||
@@ -46,6 +88,7 @@ function ProjectsPage() {
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ClipboardCheck, ListTodo, Clock, CheckCircle2 } from 'lucide-react';
|
||||
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
import { useContextualScope } from '@/hooks/useContextualScope';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
|
||||
import { TaskListView } from '@/components/tasks/TaskListView';
|
||||
@@ -11,18 +11,7 @@ export const Route = createFileRoute('/tasks')({ component: TasksPage });
|
||||
|
||||
function TasksPage() {
|
||||
const { t } = useTranslation();
|
||||
const overviewRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const { registerSection, unregisterSection } = useFloatingChat();
|
||||
|
||||
useEffect(() => {
|
||||
registerSection({ id: 'tasks-overview', label: 'Tasks Overview', ref: overviewRef });
|
||||
registerSection({ id: 'tasks-list', label: 'Task List', ref: listRef });
|
||||
return () => {
|
||||
unregisterSection('tasks-overview');
|
||||
unregisterSection('tasks-list');
|
||||
};
|
||||
}, [registerSection, unregisterSection]);
|
||||
useContextualScope({ page: 'tasks' });
|
||||
|
||||
const { data: allTasks } = trpc.tasks.list.useQuery({});
|
||||
const stats = useMemo(() => {
|
||||
@@ -36,8 +25,8 @@ function TasksPage() {
|
||||
}, [allTasks]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 pt-0 w-full">
|
||||
<div ref={overviewRef} data-ai-section="tasks-overview" className="grid grid-cols-4 gap-4">
|
||||
<div className="flex flex-col gap-6 p-6 pt-0 w-full h-full overflow-y-auto">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Item variant="muted">
|
||||
<ItemMedia variant="icon"><ClipboardCheck /></ItemMedia>
|
||||
<ItemContent><ItemTitle>{stats.total}</ItemTitle><ItemDescription>{t('tasks.totalTasks')}</ItemDescription></ItemContent>
|
||||
@@ -56,7 +45,7 @@ function TasksPage() {
|
||||
</Item>
|
||||
</div>
|
||||
|
||||
<div ref={listRef} data-ai-section="tasks-list">
|
||||
<div>
|
||||
<TaskListView />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Archive, ArchiveX } from 'lucide-react';
|
||||
import { useContextualScope } from '@/hooks/useContextualScope';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
@@ -22,6 +23,7 @@ export const Route = createFileRoute('/timeline')({
|
||||
|
||||
function TimelinePage() {
|
||||
const { t } = useTranslation();
|
||||
useContextualScope({ page: 'timeline' });
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingEvent, setEditingEvent] = useState<TimelineEvent | null>(null);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
@@ -231,6 +233,7 @@ function TimelinePage() {
|
||||
onAdd={() => setDialogOpen(true)}
|
||||
renderHeaderExtras={(compact) =>
|
||||
compact ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -246,13 +249,16 @@ function TimelinePage() {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('timeline.showArchived')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="show-archived" className="text-xs text-muted-foreground">
|
||||
{t('timeline.showArchived')}
|
||||
</Label>
|
||||
<Switch id="show-archived" checked={showArchived} onCheckedChange={setShowArchived} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
onToggleComplete={(id, current) => {
|
||||
@@ -287,8 +293,6 @@ function TimelinePage() {
|
||||
onEdit={(ev) => setEditingEvent(ev)}
|
||||
onDuplicate={handleDuplicate}
|
||||
onMove={handleMove}
|
||||
sectionId="timeline-chart"
|
||||
sectionLabel="Timeline"
|
||||
/>
|
||||
<AddEventDialog open={dialogOpen} onOpenChange={setDialogOpen} onRecordHistory={record} />
|
||||
<EditEventDialog
|
||||
|
||||
@@ -55,6 +55,7 @@ export const ToolCallActionSchema = z.enum([
|
||||
'read_project_folder_manifest',
|
||||
'read_project_folder_file',
|
||||
'list_projects_with_folder_manifests',
|
||||
'get_page_details',
|
||||
]);
|
||||
export type ToolCallAction = z.infer<typeof ToolCallActionSchema>;
|
||||
|
||||
@@ -96,19 +97,6 @@ export const WsHomeRequestSchema = z.object({
|
||||
});
|
||||
export type WsHomeRequest = z.infer<typeof WsHomeRequestSchema>;
|
||||
|
||||
export const WsFloatingRequestSchema = z.object({
|
||||
type: z.literal('floating_request'),
|
||||
message: z.string(),
|
||||
scope: z.object({
|
||||
type: z.enum(['task', 'project', 'note', 'timeline']),
|
||||
id: z.string().optional(),
|
||||
}),
|
||||
conversationHistory: z
|
||||
.array(z.object({ role: z.enum(['user', 'assistant', 'system']), content: z.string() }))
|
||||
.optional(),
|
||||
});
|
||||
export type WsFloatingRequest = z.infer<typeof WsFloatingRequestSchema>;
|
||||
|
||||
// --- Journey frames — Client → Server ----------------------------------------
|
||||
|
||||
/** Start a setup journey for custom prompt creation. */
|
||||
@@ -151,7 +139,6 @@ export const WsClientFrameSchema = z.discriminatedUnion('type', [
|
||||
WsToolResultSchema,
|
||||
WsDeviceHelloSchema,
|
||||
WsHomeRequestSchema,
|
||||
WsFloatingRequestSchema,
|
||||
WsBriefRequestSchema,
|
||||
WsTaskBriefRequestSchema,
|
||||
WsJourneyStartSchema,
|
||||
@@ -213,20 +200,6 @@ export const WsStreamEndSchema = z.object({
|
||||
});
|
||||
export type WsStreamEnd = z.infer<typeof WsStreamEndSchema>;
|
||||
|
||||
export const WsFloatingDomainSchema = z.object({
|
||||
type: z.literal('floating_domain'),
|
||||
requestId: z.string(),
|
||||
domain: z.union([
|
||||
z.enum(['tasks', 'notes', 'timelines', 'projects']),
|
||||
z.object({
|
||||
type: z.enum(['task', 'timeline', 'project', 'note', 'node']),
|
||||
id: z.string().nullable().optional(),
|
||||
section: z.enum(['task', 'timeline', 'note']).nullable().optional(),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
export type WsFloatingDomain = z.infer<typeof WsFloatingDomainSchema>;
|
||||
|
||||
// --- Journey frames — Server → Client ----------------------------------------
|
||||
|
||||
/** Server reply during a setup journey conversation. */
|
||||
@@ -313,7 +286,6 @@ export const WsServerFrameSchema = z.discriminatedUnion('type', [
|
||||
WsStreamStartSchema,
|
||||
WsStreamTextSchema,
|
||||
WsStreamEndSchema,
|
||||
WsFloatingDomainSchema,
|
||||
WsJourneyReplySchema,
|
||||
WsRunCompleteSchema,
|
||||
WsIndexFileResultSchema,
|
||||
|
||||
Reference in New Issue
Block a user