Compare commits

...

31 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Exports parseMutationsToEntityTags from useAIChat for reuse.
Also adds the missing 'stream_start' no-op switch case.
2026-05-14 19:11:10 +02:00
Roberto
44a21d662d refactor(chat): home AIChatPanel uses ChatSurface
Pure refactor, no behavior change.
2026-05-14 19:03:33 +02:00
Roberto
ae2cef4335 refactor(chat): extract ChatSurface presentational component
Shared between home and contextual channels. Variant prop selects
between home (full-width, fixed-bottom input) and contextual
(absolute-positioned translucent input with gradient fade) layouts.
2026-05-14 18:59:49 +02:00
Roberto
57462af4f4 refactor(chat): extract useChatStream hook
Shared streaming engine for home (and forthcoming contextual)
channels. useAIChat still owns cache-key + tRPC dispatch; that
wiring is migrated in the next commit.
2026-05-14 18:57:12 +02:00
Roberto
425025ad68 feat(router): add aiChat tRPC sub-router
CRUD for chat sessions and messages, used by both home and contextual
channels. No UI consumer yet — added ahead of refactor.
2026-05-14 18:53:03 +02:00
Roberto
b879760013 chore: gitignore local dev.db used by drizzle-kit push
drizzle-kit push connects to ./dev.db for local schema
verification. The file should not be tracked.
2026-05-14 18:48:55 +02:00
Roberto
21aa1db07e feat(db): add ai_chat_sessions and ai_chat_messages tables
Local chat history persistence. Same model used by both home and
contextual channels. Indexes on (session_id, created_at) and
(channel, updated_at) for ordering and listing.
2026-05-14 18:46:39 +02:00
41 changed files with 3031 additions and 1501 deletions

6
.gitignore vendored
View File

@@ -98,3 +98,9 @@ dist-web/
.vscode/
.agents/
src/renderer/routeTree.gen.ts
# Local dev SQLite (used by drizzle-kit push for schema verification)
dev.db
dev.db-journal
dev.db-shm
dev.db-wal

View File

@@ -1,7 +1,11 @@
import { defineConfig } from 'drizzle-kit';
import path from 'path';
export default defineConfig({
schema: './src/main/db/schema.ts',
out: './src/main/db/migrations',
dialect: 'sqlite',
dbCredentials: {
url: `file:${path.resolve('./dev.db')}`,
},
});

View File

@@ -3,7 +3,7 @@
*
* All AI intelligence lives on the backend. The Electron process:
* 1. Checks connectivity + auth status
* 2. Delegates to BackendClient.sendHomeRequest() / sendFloatingRequest()
* 2. Delegates to BackendClient.sendHomeRequest() / sendContextualRequest()
* which handle the WS lifecycle, tool-call ↔ DrizzleExecutor round-trips,
* and v3 stream event dispatch.
* 3. Forwards v3 typed stream frames to the renderer via IPC.
@@ -13,8 +13,6 @@ import { BrowserWindow } from 'electron';
import { getBackendClient, OfflineError, AuthExpiredError } from '../api/backend-client';
import { getAuthManager } from '../auth/auth-manager';
import { getStore } from '../store';
import type { WsFloatingRequest } from '../../shared/api-types';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
@@ -37,14 +35,12 @@ interface OrchestrateInput {
sender?: Electron.WebContents;
}
interface OrchestrateFloatingInput {
interface OrchestrateContextualInput {
message: string;
requestId?: string;
sessionId?: string;
scope: WsFloatingRequest['scope'];
conversationHistory?: WsFloatingRequest['conversationHistory'];
briefMode?: boolean;
briefingContext?: string;
scope: unknown;
conversationHistory?: Array<{ role: string; content: string }>;
sender?: Electron.WebContents;
}
@@ -127,22 +123,21 @@ export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateR
}
// ---------------------------------------------------------------------------
// Orchestrate Floating — Floating chat (public entry point)
// Orchestrate Contextual — Contextual sidebar chat (public entry point)
// ---------------------------------------------------------------------------
export async function orchestrateFloating(input: OrchestrateFloatingInput): Promise<OrchestrateResult> {
const { message, requestId, sessionId, scope, conversationHistory, briefMode, briefingContext, sender } = input;
export async function orchestrateContextual(input: OrchestrateContextualInput): Promise<OrchestrateResult> {
const { message, requestId, sessionId, scope, conversationHistory, sender } = input;
const check = await checkConnectivity();
if (!check.ok) return { response: '', error: check.error };
try {
const client = getBackendClient();
const { requestId: activeRequestId, promise } = client.sendFloatingRequest(message, scope, conversationHistory, requestId, sessionId, briefMode, briefingContext, {
const { requestId: activeRequestId, promise } = client.sendContextualRequest(message, scope, conversationHistory, requestId, sessionId, {
onStart: () => sendFrame(sender, { type: 'stream_start', requestId: activeRequestId }),
onText: (chunk) => sendFrame(sender, { type: 'stream_text', requestId: activeRequestId, chunk }),
onEnd: (mutations) => sendFrame(sender, { type: 'stream_end', requestId: activeRequestId, mutations: mutations as unknown[] | undefined }),
onDomain: (domain) => sendFrame(sender, { type: 'floating_domain', requestId: activeRequestId, domain }),
onError: () => sendFrame(sender, { type: 'stream_end', requestId: activeRequestId }),
});

View File

@@ -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 () => {

View File

@@ -13,7 +13,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { eq, and, or, like, isNull, asc, desc, gte, lte, sql, SQL } from 'drizzle-orm';
import { eq, and, or, like, isNull, asc, desc, gte, lte, inArray, sql, SQL } from 'drizzle-orm';
import { getDb } from '../db';
import { tasks, projects, clients, timelineEvents, notes, noteEdits, taskComments, projectFolderFiles } from '../db/schema';
import type { WsToolCall } from '../../shared/api-types';
@@ -100,6 +100,20 @@ function buildConditions(
if (value === null) {
conditions.push(isNull(col as Parameters<typeof isNull>[0]));
} else if (typeof value === 'string' && value.includes(',')) {
const parts = value.split(',').map((s) => s.trim()).filter(Boolean);
if (parts.length > 1) {
conditions.push(inArray(col as Parameters<typeof inArray>[0], parts));
} else if (parts.length === 1) {
conditions.push(eq(col as Parameters<typeof eq>[0], parts[0]));
}
} else if (Array.isArray(value)) {
const parts = value.map((v) => String(v)).filter(Boolean);
if (parts.length > 1) {
conditions.push(inArray(col as Parameters<typeof inArray>[0], parts));
} else if (parts.length === 1) {
conditions.push(eq(col as Parameters<typeof eq>[0], parts[0]));
}
} else {
conditions.push(eq(col as Parameters<typeof eq>[0], value as Parameters<typeof eq>[1]));
}
@@ -194,6 +208,8 @@ export class DrizzleExecutor {
return this.handleReadProjectFolderFile(payload);
case 'list_projects_with_folder_manifests':
return this.handleListProjectsWithFolderManifests();
case 'get_page_details':
return this.handleGetPageDetails(payload);
default:
throw new ExecutorError(`Unknown action: "${action as string}"`);
}
@@ -552,6 +568,72 @@ export class DrizzleExecutor {
}
}
// -------------------------------------------------------------------------
// Contextual agent: composite read op
// -------------------------------------------------------------------------
private handleGetPageDetails(payload: WsToolCall): Record<string, unknown> {
const db = getDb();
// entity_type is sent as the `table` field by the backend execute_on_client call.
const entityType = payload.table ?? '';
const entityId = (payload.data?.['entityId'] as string | null | undefined) ?? undefined;
switch (entityType) {
case 'project': {
if (!entityId) throw new ExecutorError('get_page_details: entityId required for project');
const project = db.select().from(projects).where(eq(projects.id, entityId)).get() ?? null;
if (!project) return { project: null, tasks: [], notes: [], milestones: [], events: [] };
const projectTasks = db.select().from(tasks).where(eq(tasks.projectId, entityId)).all();
const projectNotes = db
.select({
id: notes.id,
title: notes.title,
aiSummary: notes.aiSummary,
updatedAt: notes.updatedAt,
})
.from(notes)
.where(eq(notes.projectId, entityId))
.all();
const events = db.select().from(timelineEvents).where(eq(timelineEvents.projectId, entityId)).all();
return {
project,
tasks: projectTasks,
notes: projectNotes,
milestones: events.filter((e) => e.type === 'milestone'),
events,
};
}
case 'task': {
if (!entityId) throw new ExecutorError('get_page_details: entityId required for task');
const task = db.select().from(tasks).where(eq(tasks.id, entityId)).get() ?? null;
const project = task?.projectId
? (db.select().from(projects).where(eq(projects.id, task.projectId)).get() ?? null)
: null;
const comments = db.select().from(taskComments).where(eq(taskComments.taskId, entityId)).all();
return { task, project, comments };
}
case 'note': {
if (!entityId) throw new ExecutorError('get_page_details: entityId required for note');
const note = db.select().from(notes).where(eq(notes.id, entityId)).get() ?? null;
return { note };
}
case 'tasks_all':
return { tasks: db.select().from(tasks).all() };
case 'projects_all':
return { projects: db.select().from(projects).all() };
case 'timeline_all':
return { events: db.select().from(timelineEvents).all() };
default:
throw new ExecutorError(`get_page_details: unknown entityType "${entityType}"`);
}
}
private handleListProjectsWithFolderManifests(): Record<string, unknown> {
const projs = getDb()
.select()

View File

@@ -0,0 +1,23 @@
CREATE TABLE `ai_chat_messages` (
`id` text PRIMARY KEY NOT NULL,
`session_id` text NOT NULL,
`role` text NOT NULL,
`content` text NOT NULL,
`tool_calls` text,
`tool_results` text,
`scope` text,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `ai_chat_sessions` (
`id` text PRIMARY KEY NOT NULL,
`channel` text NOT NULL,
`title` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`last_scope` text
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `ai_chat_messages_session_created_idx` ON `ai_chat_messages` (`session_id`, `created_at`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `ai_chat_sessions_channel_updated_idx` ON `ai_chat_sessions` (`channel`, `updated_at`);

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

@@ -0,0 +1,105 @@
// adiuvAI/src/main/router/ai-chat.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
import { eq, desc, asc } from 'drizzle-orm';
import { getDb } from '../db';
import { aiChatSessions, aiChatMessages } from '../db/schema';
import type { TRPCContext } from '../ipc';
const t = initTRPC.context<TRPCContext>().create();
const router = t.router;
const publicProcedure = t.procedure;
const ChannelSchema = z.enum(['home', 'contextual']);
const RoleSchema = z.enum(['user', 'assistant', 'system']);
export const aiChatRouter = router({
listSessions: publicProcedure
.input(z.object({ channel: ChannelSchema }))
.query(({ input }) => {
return getDb()
.select()
.from(aiChatSessions)
.where(eq(aiChatSessions.channel, input.channel))
.orderBy(desc(aiChatSessions.updatedAt))
.all();
}),
getSession: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const db = getDb();
const session = db
.select()
.from(aiChatSessions)
.where(eq(aiChatSessions.id, input.id))
.get();
if (!session) return null;
const messages = db
.select()
.from(aiChatMessages)
.where(eq(aiChatMessages.sessionId, input.id))
.orderBy(asc(aiChatMessages.createdAt))
.all();
return { session, messages };
}),
createSession: publicProcedure
.input(z.object({
channel: ChannelSchema,
initialScope: z.string().optional(),
}))
.mutation(({ input }) => {
const db = getDb();
const id = crypto.randomUUID();
const now = Date.now();
db.insert(aiChatSessions).values({
id,
channel: input.channel,
title: null,
createdAt: now,
updatedAt: now,
lastScope: input.initialScope ?? null,
}).run();
return { id };
}),
appendMessage: publicProcedure
.input(z.object({
sessionId: z.string(),
role: RoleSchema,
content: z.string(),
toolCalls: z.string().optional(),
toolResults: z.string().optional(),
scope: z.string().optional(),
}))
.mutation(({ input }) => {
const db = getDb();
const id = crypto.randomUUID();
const now = Date.now();
db.insert(aiChatMessages).values({
id,
sessionId: input.sessionId,
role: input.role,
content: input.content,
toolCalls: input.toolCalls ?? null,
toolResults: input.toolResults ?? null,
scope: input.scope ?? null,
createdAt: now,
}).run();
db.update(aiChatSessions)
.set({ updatedAt: now, lastScope: input.scope ?? null })
.where(eq(aiChatSessions.id, input.sessionId))
.run();
return { id };
}),
deleteSession: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(({ input }) => {
const db = getDb();
db.delete(aiChatMessages).where(eq(aiChatMessages.sessionId, input.id)).run();
db.delete(aiChatSessions).where(eq(aiChatSessions.id, input.id)).run();
return { ok: true };
}),
});

View File

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

View File

@@ -25,21 +25,7 @@ const AI_STREAM_CHANNEL = 'ai:stream';
type V3StreamEvent =
| { type: 'stream_start'; requestId: string }
| { type: 'stream_text'; requestId: string; chunk: string }
| { type: 'stream_end'; requestId: string; mutations?: unknown[] }
| {
type: 'floating_domain';
requestId: string;
domain:
| 'tasks'
| 'notes'
| 'timelines'
| 'projects'
| {
type: 'task' | 'timeline' | 'project' | 'note' | 'node';
id?: string | null;
section?: 'task' | 'timeline' | 'note' | null;
};
};
| { type: 'stream_end'; requestId: string; mutations?: unknown[] };
contextBridge.exposeInMainWorld('electronAI', {
/** Subscribe to v3 AI stream events. Returns an unsubscribe function. */
@@ -58,6 +44,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),
});
// ---------------------------------------------------------------------------

View File

@@ -1,8 +1,6 @@
import { useState, useEffect, useRef, useCallback, useMemo, forwardRef, memo } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Link } from '@tanstack/react-router';
import { Sparkles, LogIn, ListTodo, TrendingUp, AlertCircle, Lightbulb, ChevronDown, ChevronUp, X } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { LogIn, ListTodo, TrendingUp, AlertCircle, Lightbulb, ChevronDown, ChevronUp, X, Sparkles } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { trpc } from '@/lib/trpc';
import { useAIChat, type UIChatContext } from '@/hooks/useAIChat';
@@ -14,148 +12,11 @@ import { useTaskBriefing } from '@/context/TaskBriefingContext';
import { TaskBriefingOverlay } from '@/components/brief/TaskBriefingOverlay';
import { ScrollArea } from '@/components/ui/scroll-area';
import { GradualBlur } from '@/components/ui/gradual-blur';
import { ChatEntityBlock } from './blocks/ChatEntityBlock';
import { ChatChartBlock } from './blocks/ChatChartBlock';
import type { EntityRefBlockData, ChartBlockData } from '../../../shared/api-types';
import { ChatSurface, MessageContent, ChatMarkdown } from './ChatSurface';
/** Fluid font size for chat messages — scales with viewport width */
// const CHAT_FONT = '1rem';
// ---------------------------------------------------------------------------
// Inline tag parsing (entities + charts)
// ---------------------------------------------------------------------------
/**
* Matches entity tags in both formats:
* - <task>[id1,id2]</task>
* - <timeline>id1,id2</timeline>
*/
const ENTITY_TAG_RE = /<(?<entity>task|project|note|timeline|timelineEvent)>(?:\[(?<bracketIds>[^\]]+)\]|(?<plainIds>[^<]+))<\/\k<entity>>/;
/** Matches chart tags: <chart>{...JSON...}</chart> */
const CHART_TAG_RE = /<chart>(?<chartJson>\{[\s\S]*?\})<\/chart>/;
/** Combined: matches the first occurrence of either tag */
const INLINE_TAG_RE = new RegExp(`${ENTITY_TAG_RE.source}|${CHART_TAG_RE.source}`);
type ContentSegment =
| { type: 'text'; content: string }
| { type: 'entity'; entity: EntityRefBlockData['entity']; ids: string[] }
| { type: 'chart'; data: ChartBlockData };
function parseInlineTags(content: string): ContentSegment[] {
const segments: ContentSegment[] = [];
let remaining = content;
while (remaining) {
const match = INLINE_TAG_RE.exec(remaining);
if (!match) {
segments.push({ type: 'text', content: remaining });
break;
}
const before = remaining.slice(0, match.index);
if (before) segments.push({ type: 'text', content: before });
const groups = match.groups ?? {};
if (groups.entity) {
const entity = groups.entity as EntityRefBlockData['entity'];
const rawIds = groups.bracketIds ?? groups.plainIds ?? '';
const ids = rawIds.split(',').map((id) => id.trim()).filter(Boolean);
segments.push({ type: 'entity', entity, ids });
} else if (groups.chartJson) {
try {
const chartData = JSON.parse(groups.chartJson) as ChartBlockData;
segments.push({ type: 'chart', data: chartData });
} catch {
// Malformed JSON — keep as text
segments.push({ type: 'text', content: match[0] });
}
}
const matchIndex = typeof match.index === 'number' ? match.index : 0;
remaining = remaining.slice(matchIndex + match[0].length);
}
return segments;
}
function hasInlineTags(content: string): boolean {
return INLINE_TAG_RE.test(content);
}
function mergeTimelineSegments(segments: ContentSegment[]): ContentSegment[] {
const allTimelineIds: string[] = [];
for (const seg of segments) {
if (seg.type === 'entity' && (seg.entity === 'timeline' || seg.entity === 'timelineEvent')) {
allTimelineIds.push(...seg.ids);
}
}
const uniqueTimelineIds = [...new Set(allTimelineIds)];
if (!uniqueTimelineIds.length) return segments;
const merged: ContentSegment[] = [];
let lastTimelineInsertIndex = 0;
for (const seg of segments) {
if (seg.type === 'entity' && (seg.entity === 'timeline' || seg.entity === 'timelineEvent')) {
lastTimelineInsertIndex = merged.length;
continue;
}
merged.push(seg);
}
// Keep prose flow untouched and place consolidated timeline at the last timeline tag position.
merged.splice(lastTimelineInsertIndex, 0, { type: 'entity', entity: 'timeline', ids: uniqueTimelineIds });
return merged.filter((seg) => !(seg.type === 'text' && !seg.content.trim()));
}
function mergeConsecutiveTaskSegments(segments: ContentSegment[]): ContentSegment[] {
const merged: ContentSegment[] = [];
for (let i = 0; i < segments.length; i += 1) {
const current = segments[i];
if (!current) continue;
if (!(current?.type === 'entity' && current.entity === 'task')) {
merged.push(current);
continue;
}
const groupedIds: string[] = [...current.ids];
let j = i + 1;
// Merge only adjacent task tags, allowing whitespace-only text between them.
while (j < segments.length) {
const next = segments[j];
if (next?.type === 'text' && !next.content.trim()) {
j += 1;
continue;
}
if (next?.type === 'entity' && next.entity === 'task') {
groupedIds.push(...next.ids);
j += 1;
continue;
}
break;
}
merged.push({
type: 'entity',
entity: 'task',
ids: [...new Set(groupedIds)],
});
i = j - 1;
}
return merged;
}
const SUGGESTION_CHIPS = [
{ icon: ListTodo, labelKey: 'home.chipWhatsOnMyPlate' },
@@ -219,6 +80,46 @@ export function AIChatPanel({
const profile = authStatusQuery.data?.profile;
const dueTodayQuery = trpc.tasks.dueToday.useQuery(undefined, { enabled: !!isHomePage });
// ---------------------------------------------------------------------------
// Home chat SQLite persistence (M2.4)
// ---------------------------------------------------------------------------
const HOME_SESSION_KEY = 'chat.home.lastSessionId';
const [homeSessionId, setHomeSessionId] = useState<string | null>(() =>
typeof window !== 'undefined' ? window.localStorage.getItem(HOME_SESSION_KEY) : null,
);
const createSession = trpc.aiChat.createSession.useMutation();
const appendMessage = trpc.aiChat.appendMessage.useMutation();
useEffect(() => {
let cancelled = false;
(async () => {
if (!homeSessionId) {
const { id } = await createSession.mutateAsync({ channel: 'home' });
if (cancelled) return;
window.localStorage.setItem(HOME_SESSION_KEY, id);
setHomeSessionId(id);
} else {
// Verify the session still exists. If row is missing (e.g. user
// deleted the db file), recreate.
const res = await utils.aiChat.getSession.fetch({ id: homeSessionId });
if (cancelled) return;
if (!res) {
const { id } = await createSession.mutateAsync({ channel: 'home' });
if (cancelled) return;
window.localStorage.setItem(HOME_SESSION_KEY, id);
setHomeSessionId(id);
}
// Note: hydrating past messages into useAIChat's in-memory cache
// is deferred to a follow-up task. Current behavior matches
// the previous in-memory cache lifetime.
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [homeSessionId]);
const chatContext = useMemo<UIChatContext>(
() => ({ type: 'global' as const }),
[],
@@ -231,6 +132,28 @@ export function AIChatPanel({
clearMessages,
cacheKey,
} = useAIChat(chatContext);
// Persist each new user/assistant message to aiChatMessages in SQLite.
const persistedCountRef = useRef(0);
useEffect(() => {
if (!homeSessionId) return;
// Reset cursor when session changes or messages are cleared (new chat).
if (persistedCountRef.current > messages.length) {
persistedCountRef.current = 0;
}
const fresh = messages.slice(persistedCountRef.current);
for (const m of fresh) {
appendMessage.mutate({
sessionId: homeSessionId,
role: m.role,
content: m.content,
});
}
persistedCountRef.current = messages.length;
// appendMessage is stable (useMutation ref), intentionally omitted from deps.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messages, homeSessionId]);
const hasMessages = messages.length > 0 || isStreaming;
// Notify parent when conversation active state changes
@@ -264,6 +187,13 @@ export function AIChatPanel({
clearMessages();
aiMinHeightCache = null;
setAiMinHeight(null);
// Create a new SQLite session for the next conversation.
createSession.mutateAsync({ channel: 'home' }).then(({ id }) => {
window.localStorage.setItem(HOME_SESSION_KEY, id);
setHomeSessionId(id);
}).catch(() => {
// Non-fatal: next message will still attempt session verification.
});
},
};
}
@@ -602,74 +532,19 @@ export function AIChatPanel({
</motion.div>
)}
{/* Home page with messages: brief stays, then messages */}
{/* Home page with messages: brief stays, then messages via ChatSurface */}
{isHomePage && hasMessages && (
<div className="mx-auto w-full max-w-6xl px-6 pt-8">
<div className="flex flex-col gap-4">
<div
aria-hidden
style={{
height: '4vw',
flexShrink: 0,
}}
<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';

View File

@@ -0,0 +1,17 @@
export interface AdiuvaIconProps {
size?: number;
}
export function AdiuvaIcon({ size = 24 }: AdiuvaIconProps) {
return (
<img
src="/logo/logo-mark.svg"
width={size}
height={size}
alt=""
aria-hidden="true"
draggable={false}
className="adiuva-mark-img select-none pointer-events-none"
/>
);
}

View File

@@ -0,0 +1,17 @@
import { useContextualChat } from '@/context/ContextualChatContext';
import { AdiuvaIcon } from './AdiuvaIcon';
export function AdiuvaTriggerButton() {
const { toggle, open } = useContextualChat();
return (
<button
type="button"
onClick={toggle}
title="Ask adiuvAI"
aria-pressed={open}
className="adiuva-btn sm"
>
<AdiuvaIcon size={24} />
</button>
);
}

View File

@@ -9,7 +9,7 @@ export interface ChatInputBoxHandle {
focus: () => void;
}
type ChatInputBoxVariant = 'panel' | 'floating' | 'comment';
type ChatInputBoxVariant = 'panel' | 'comment';
interface ChatInputBoxProps {
cacheKey: string;
@@ -27,12 +27,6 @@ const VARIANT_STYLES = {
button: 'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100',
iconSize: 16,
},
floating: {
container: 'flex items-center gap-2 px-3 py-2.5',
textarea: 'flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground/60 outline-none max-h-20 overflow-y-auto',
button: 'flex h-7 w-7 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed',
iconSize: 14,
},
comment: {
container: 'flex items-center gap-2 px-3 py-2',
textarea: 'flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground outline-none max-h-32 overflow-y-auto',
@@ -49,7 +43,7 @@ export const ChatInputBox = forwardRef<ChatInputBoxHandle, ChatInputBoxProps>(
const valueRef = useRef(value);
valueRef.current = value;
// Re-init when the cache key changes (context switches in FloatingChat).
// Re-init when the cache key changes (context switches).
const prevKeyRef = useRef(cacheKey);
useEffect(() => {
if (prevKeyRef.current !== cacheKey) {

View File

@@ -0,0 +1,502 @@
/**
* ChatSurface — pure presentational chat surface.
*
* Contains:
* - Message list rendering (user bubbles, AI messages with Sparkles header,
* inline entity/chart block parsing, error styling)
* - Streaming content placeholder
* - Scroll management
* - ChatInputBox wrapper
*
* Also exports shared rendering utilities (ChatMarkdown, MessageContent,
* AIMessage) so AIChatPanel can import them here and avoid circular deps.
*/
import {
memo,
forwardRef,
useMemo,
useEffect,
useRef,
} from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Sparkles } from 'lucide-react';
import { motion } from 'framer-motion';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { ChatEntityBlock } from './blocks/ChatEntityBlock';
import { ChatChartBlock } from './blocks/ChatChartBlock';
import { ChatInputBox, type ChatInputBoxHandle } from './ChatInputBox';
import type { ChatMessage } from '@/hooks/useAIChat';
import type { EntityRefBlockData, ChartBlockData } from '../../../shared/api-types';
// ---------------------------------------------------------------------------
// Inline tag parsing (mirrors AIChatPanel)
// ---------------------------------------------------------------------------
const ENTITY_TAG_RE =
/<(?<entity>task|project|note|timeline|timelineEvent)>(?:\[(?<bracketIds>[^\]]+)\]|(?<plainIds>[^<]+))<\/\k<entity>>/;
const CHART_TAG_RE = /<chart>(?<chartJson>\{[\s\S]*?\})<\/chart>/;
const INLINE_TAG_RE = new RegExp(`${ENTITY_TAG_RE.source}|${CHART_TAG_RE.source}`);
type ContentSegment =
| { type: 'text'; content: string }
| { type: 'entity'; entity: EntityRefBlockData['entity']; ids: string[] }
| { type: 'chart'; data: ChartBlockData };
function parseInlineTags(content: string): ContentSegment[] {
const segments: ContentSegment[] = [];
let remaining = content;
while (remaining) {
const match = INLINE_TAG_RE.exec(remaining);
if (!match) {
segments.push({ type: 'text', content: remaining });
break;
}
const before = remaining.slice(0, match.index);
if (before) segments.push({ type: 'text', content: before });
const groups = match.groups ?? {};
if (groups.entity) {
const entity = groups.entity as EntityRefBlockData['entity'];
const rawIds = groups.bracketIds ?? groups.plainIds ?? '';
const ids = rawIds
.split(',')
.map((id) => id.trim())
.filter(Boolean);
segments.push({ type: 'entity', entity, ids });
} else if (groups.chartJson) {
try {
const chartData = JSON.parse(groups.chartJson) as ChartBlockData;
segments.push({ type: 'chart', data: chartData });
} catch {
segments.push({ type: 'text', content: match[0] });
}
}
const matchIndex = typeof match.index === 'number' ? match.index : 0;
remaining = remaining.slice(matchIndex + match[0].length);
}
return segments;
}
function hasInlineTags(content: string): boolean {
return INLINE_TAG_RE.test(content);
}
function mergeTimelineSegments(segments: ContentSegment[]): ContentSegment[] {
const allTimelineIds: string[] = [];
for (const seg of segments) {
if (seg.type === 'entity' && (seg.entity === 'timeline' || seg.entity === 'timelineEvent')) {
allTimelineIds.push(...seg.ids);
}
}
const uniqueTimelineIds = [...new Set(allTimelineIds)];
if (!uniqueTimelineIds.length) return segments;
const merged: ContentSegment[] = [];
let lastTimelineInsertIndex = 0;
for (const seg of segments) {
if (seg.type === 'entity' && (seg.entity === 'timeline' || seg.entity === 'timelineEvent')) {
lastTimelineInsertIndex = merged.length;
continue;
}
merged.push(seg);
}
merged.splice(lastTimelineInsertIndex, 0, {
type: 'entity',
entity: 'timeline',
ids: uniqueTimelineIds,
});
return merged.filter((seg) => !(seg.type === 'text' && !seg.content.trim()));
}
function mergeConsecutiveTaskSegments(segments: ContentSegment[]): ContentSegment[] {
const merged: ContentSegment[] = [];
for (let i = 0; i < segments.length; i += 1) {
const current = segments[i];
if (!current) continue;
if (!(current?.type === 'entity' && current.entity === 'task')) {
merged.push(current);
continue;
}
const groupedIds: string[] = [...current.ids];
let j = i + 1;
while (j < segments.length) {
const next = segments[j];
if (next?.type === 'text' && !next.content.trim()) {
j += 1;
continue;
}
if (next?.type === 'entity' && next.entity === 'task') {
groupedIds.push(...next.ids);
j += 1;
continue;
}
break;
}
merged.push({
type: 'entity',
entity: 'task',
ids: [...new Set(groupedIds)],
});
i = j - 1;
}
return merged;
}
// ---------------------------------------------------------------------------
// ChatMarkdown — lightweight markdown renderer with GFM + styled code blocks
// ---------------------------------------------------------------------------
const REMARK_PLUGINS: Parameters<typeof ReactMarkdown>[0]['remarkPlugins'] = [remarkGfm];
const MARKDOWN_COMPONENTS: Parameters<typeof ReactMarkdown>[0]['components'] = {
pre: ({ children }) => (
<pre className="bg-muted rounded-lg p-3 overflow-x-auto text-xs">{children}</pre>
),
code: ({ children, className }: { children?: React.ReactNode; className?: string }) => {
if (!className) {
return (
<code className="bg-muted rounded px-1.5 py-0.5 text-xs font-mono">{children}</code>
);
}
return <code className={className}>{children}</code>;
},
};
export function ChatMarkdown({
content,
size = 'sm',
fontSize,
}: {
content: string;
size?: 'sm' | 'lg';
fontSize?: string;
}) {
return (
<div
className={`prose dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${size === 'lg' ? 'prose-base' : 'prose-sm'}`}
style={fontSize ? { fontSize } : undefined}
>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
{content}
</ReactMarkdown>
</div>
);
}
// ---------------------------------------------------------------------------
// MessageContent — text with inline entity + chart blocks
// ---------------------------------------------------------------------------
const blockAnimation = {
initial: { opacity: 0, scale: 0.95 },
animate: { opacity: 1, scale: 1 },
transition: { type: 'spring' as const, stiffness: 400, damping: 30 },
};
export const MessageContent = memo(function MessageContent({
content,
fontSize,
}: {
content: string;
fontSize?: string;
}) {
const segments = useMemo(
() => mergeConsecutiveTaskSegments(mergeTimelineSegments(parseInlineTags(content))),
[content],
);
if (segments.length === 1 && segments[0]?.type === 'text') {
return <ChatMarkdown content={content} fontSize={fontSize} />;
}
if (segments.length === 0) return null;
return (
<div className="flex flex-col gap-3">
{segments.map((seg, i) => {
if (seg.type === 'text') {
return <ChatMarkdown key={i} content={seg.content} fontSize={fontSize} />;
}
if (seg.type === 'chart') {
return (
<motion.div key={i} {...blockAnimation}>
<ChatChartBlock data={seg.data} />
</motion.div>
);
}
return (
<motion.div key={i} {...blockAnimation}>
<ChatEntityBlock data={{ entity: seg.entity, ids: seg.ids }} />
</motion.div>
);
})}
</div>
);
});
// ---------------------------------------------------------------------------
// AIMessage — shared layout for completed + streaming AI turns
// ---------------------------------------------------------------------------
interface AIMessageProps {
content: string;
bottomPad?: boolean;
minHeight?: number | null;
skeleton?: boolean;
}
export const AIMessage = memo(
forwardRef<HTMLDivElement, AIMessageProps>(({ content, bottomPad, minHeight, skeleton }, ref) => (
<div
ref={ref}
className={`mr-auto ${hasInlineTags(content) ? 'w-full' : 'max-w-[75%]'}`}
style={minHeight ? { minHeight } : undefined}
>
<div className="flex items-end gap-2.5 mb-1">
<Sparkles size={24} className="text-foreground" />
<span className="text-xl font-semibold leading-none">
adiuv<span className="font-bold text-primary">AI</span>
</span>
</div>
{skeleton ? (
<div className="space-y-2 pl-[32px] pb-40">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-32" />
</div>
) : (
<div className={`pl-[32px] flex flex-col gap-3${bottomPad ? ' pb-40' : ''}`}>
<MessageContent content={content} />
</div>
)}
</div>
)),
);
AIMessage.displayName = 'AIMessage';
// ---------------------------------------------------------------------------
// ChatSurface props
// ---------------------------------------------------------------------------
export interface ChatSurfaceProps {
messages: ChatMessage[];
streamingContent: string;
isStreaming: boolean;
onSend: (text: string) => void;
cacheKey: string;
variant: 'home' | 'contextual';
/** Slot rendered just above the input area (e.g. suggestion chips). */
aboveInputSlot?: React.ReactNode;
/** Extra bottom padding for the message list (default 120px). */
bottomPadPx?: number;
/** Ref forwarded to the ChatInputBox for imperative control. */
inputRef?: React.Ref<ChatInputBoxHandle>;
/** minHeight applied to the last AI message (home page scroll behaviour). */
aiMinHeight?: number | null;
/** Ref set on the last user message div for scroll targeting. */
lastUserMsgRef?: React.RefObject<HTMLDivElement | null>;
/** Ref set on the last AI message div. */
lastAiRef?: React.RefObject<HTMLDivElement | null>;
/** Additional class names for the scroll area viewport. */
viewportClassName?: string;
/** Whether the scroll area has messages (controls scrollbar z-index). */
hasMessages?: boolean;
/** i18n placeholder for the input field. */
placeholder?: string;
/** Hint shown when messages are empty and not streaming. Used by the contextual variant. */
emptyStateCopy?: React.ReactNode;
}
// ---------------------------------------------------------------------------
// ChatSurface component
// ---------------------------------------------------------------------------
export const ChatSurface = memo(function ChatSurface({
messages,
streamingContent,
isStreaming,
onSend,
cacheKey,
variant,
aboveInputSlot,
inputRef,
aiMinHeight,
lastUserMsgRef,
lastAiRef,
viewportClassName,
hasMessages,
placeholder,
emptyStateCopy,
}: ChatSurfaceProps) {
// Internal scroll ref — used only in contextual variant where we don't have
// the parent-managed scroll refs from the home path.
const internalScrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (variant !== 'contextual') return;
internalScrollRef.current?.scrollTo({
top: internalScrollRef.current.scrollHeight,
behavior: 'smooth',
});
}, [messages.length, streamingContent, variant]);
if (variant === 'home') {
// Home variant: delegates scroll management entirely to the parent
// (AIChatPanel owns ScrollArea + scroll-to-user-message logic).
// Renders only the message list rows + fixed input footer.
return (
<>
{/* Message list — rendered inside parent's ScrollArea */}
<div className="mx-auto w-full max-w-6xl px-6 pt-8">
<div className="flex flex-col gap-4">
<div aria-hidden style={{ height: '4vw', flexShrink: 0 }} />
{messages.map((msg, idx) => {
const isLast = idx === messages.length - 1;
const isLastUser = isLast && msg.role === 'user';
const isLastAssistant = isLast && msg.role === 'assistant' && !isStreaming;
if (msg.role === 'user') {
return (
<div
key={msg.id}
ref={isLastUser ? lastUserMsgRef : undefined}
className="flex justify-end"
>
<div className="ml-auto max-w-[75%] rounded-2xl bg-muted px-4 py-2">
<ChatMarkdown content={msg.content} />
</div>
</div>
);
}
if (msg.error) {
return (
<div key={msg.id} className="mr-auto max-w-[75%]">
<p className="text-destructive whitespace-pre-wrap">{msg.content}</p>
</div>
);
}
return (
<AIMessage
key={msg.id}
ref={isLastAssistant ? lastAiRef : undefined}
content={msg.content}
bottomPad={isLastAssistant}
minHeight={isLastAssistant ? aiMinHeight : undefined}
/>
);
})}
{isStreaming && (
<AIMessage
ref={lastAiRef}
content={streamingContent}
bottomPad
minHeight={aiMinHeight}
skeleton={!streamingContent}
/>
)}
</div>
</div>
{/* Above-input slot (suggestion chips, etc.) rendered by parent */}
{aboveInputSlot}
</>
);
}
// ---------------------------------------------------------------------------
// Contextual variant — self-contained scroll + absolute-positioned input
// ---------------------------------------------------------------------------
return (
<div className="flex flex-col h-full relative">
<ScrollArea
className="h-full"
scrollbarClassName={hasMessages ? 'z-30' : undefined}
viewportClassName={viewportClassName}
>
<div
ref={internalScrollRef}
className="flex flex-col gap-4 px-4"
style={{ paddingBottom: 120, paddingTop: 64 }}
>
{messages.length === 0 && !isStreaming && variant === 'contextual' && emptyStateCopy && (
<div className="text-center text-xs text-muted-foreground py-12 px-6 leading-relaxed">
{emptyStateCopy}
</div>
)}
{messages.map((msg) => {
if (msg.role === 'user') {
return (
<div key={msg.id} className="flex justify-end">
<div className="ml-auto max-w-[75%] rounded-2xl bg-muted px-4 py-2">
<ChatMarkdown content={msg.content} />
</div>
</div>
);
}
if (msg.error) {
return (
<div key={msg.id} className="mr-auto max-w-[75%]">
<p className="text-destructive whitespace-pre-wrap">{msg.content}</p>
</div>
);
}
return <AIMessage key={msg.id} content={msg.content} />;
})}
{isStreaming && (
<AIMessage
content={streamingContent}
skeleton={!streamingContent}
/>
)}
</div>
</ScrollArea>
{aboveInputSlot}
{/* Absolute-positioned input with gradient fade */}
<div className="absolute inset-x-0 bottom-0 px-4 pb-3 pointer-events-none">
<div
className="h-16 -mx-4 -mt-16 pointer-events-none"
style={{
background:
'linear-gradient(to bottom, transparent 0%, color-mix(in srgb, var(--background) 90%, transparent) 60%, var(--background) 100%)',
}}
/>
<div className="pointer-events-auto relative rounded-2xl bg-background/70 backdrop-blur-xl border border-border/50 shadow-lg ring-1 ring-border/20 transition-shadow focus-within:shadow-xl focus-within:border-ring/50">
<ChatInputBox
ref={inputRef}
onSend={onSend}
isStreaming={isStreaming}
cacheKey={cacheKey}
placeholder={placeholder}
/>
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,85 @@
import { useMemo } from 'react';
import { SquarePen } from 'lucide-react';
import { useContextualChat, type ContextualScope } from '@/context/ContextualChatContext';
import { ChatSurface } from './ChatSurface';
function scopeLabel(scope: ContextualScope | null): string | null {
if (!scope) return null;
switch (scope.page) {
case 'timeline':
return 'Timeline';
case 'tasks':
return 'Tasks';
case 'projects-list':
return 'Projects';
case 'project':
return scope.entityName ? `Project · ${scope.entityName}` : 'Project';
case 'note':
return scope.entityName ? `Note · ${scope.entityName}` : 'Note';
default:
return null;
}
}
export function ContextualSidebar() {
const { messages, isStreaming, streamingContent, send, newChat, sessionId, scope } =
useContextualChat();
const label = scopeLabel(scope);
const emptyStateCopy = useMemo(() => {
if (!scope) return null;
switch (scope.page) {
case 'tasks':
return 'Ask anything about your tasks — or "create a task for…"';
case 'projects-list':
return 'Ask about your projects, or kick off a new one.';
case 'timeline':
return "Ask about milestones, what's coming up, or what's overdue.";
case 'project':
return scope.entityName
? `Ask anything about ${scope.entityName} — recap, tasks, status.`
: null;
case 'note':
return scope.entityName
? `Ask about ${scope.entityName}. (Note editing comes in a later release.)`
: null;
default:
return null;
}
}, [scope]);
return (
<div className="relative h-full w-full bg-transparent">
<div className="absolute top-[10px] left-[8px] z-10 flex items-center gap-2">
<button
type="button"
onClick={() => {
void newChat();
}}
aria-label="New conversation"
title="New chat"
className="flex h-6 w-6 items-center justify-center rounded-sm bg-background/60 text-muted-foreground backdrop-blur-md transition-colors hover:text-foreground hover:bg-accent"
>
<SquarePen size={14} />
</button>
{label && (
<div
className="inline-flex h-6 items-center rounded-sm bg-background/60 px-2 text-[11px] font-medium text-muted-foreground backdrop-blur-md"
title={`Current context: ${label}`}
>
{label}
</div>
)}
</div>
<ChatSurface
messages={messages}
streamingContent={streamingContent}
isStreaming={isStreaming}
onSend={send}
cacheKey={`contextual:${sessionId ?? 'none'}`}
variant="contextual"
emptyStateCopy={emptyStateCopy}
/>
</div>
);
}

View File

@@ -1,438 +0,0 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { useNavigate, useRouterState } from '@tanstack/react-router';
import { X } from 'lucide-react';
import {
useFloatingChat,
computeDualAnchor,
getChatWidth,
CHAT_HEIGHT,
PADDING,
} from '@/context/FloatingChatContext';
import { useAIChat, type UIChatContext, type FloatingDomainSignal } from '@/hooks/useAIChat';
import { ChatMarkdown } from '@/components/ai/AIChatPanel';
import { ChatInputBox, type ChatInputBoxHandle } from '@/components/ai/ChatInputBox';
import { Skeleton } from '@/components/ui/skeleton';
/** Map floating_domain signals to routes for background navigation */
const DOMAIN_ROUTES: Record<string, string> = {
tasks: '/tasks',
notes: '/notes',
timelines: '/timeline',
projects: '/projects',
};
const DOMAIN_SECTION_IDS: Partial<Record<'tasks' | 'notes' | 'timelines' | 'projects', string>> = {
tasks: 'tasks-list',
timelines: 'timeline-chart',
};
interface DomainNavigationTarget {
route: '/tasks' | '/timeline' | '/projects' | '/notes/$noteId';
sectionId?: string;
projectId?: string;
noteId?: string;
nodeId?: string;
}
function normalizeDomainSignal(domain: FloatingDomainSignal): DomainNavigationTarget | null {
if (typeof domain === 'string') {
const route = DOMAIN_ROUTES[domain];
if (!route) return null;
return {
route: route as DomainNavigationTarget['route'],
sectionId: DOMAIN_SECTION_IDS[domain as keyof typeof DOMAIN_SECTION_IDS],
};
}
switch (domain.type) {
case 'task':
return { route: '/tasks', sectionId: 'tasks-list' };
case 'timeline':
return { route: '/timeline', sectionId: 'timeline-chart' };
case 'note':
if (!domain.id) return { route: '/projects' };
return { route: '/notes/$noteId', noteId: domain.id };
case 'project': {
if (domain.section === 'task') {
return { route: '/projects', sectionId: 'project-tasks', projectId: domain.id ?? undefined };
}
if (domain.section === 'timeline') {
return { route: '/projects', sectionId: 'project-timeline', projectId: domain.id ?? undefined };
}
if (domain.section === 'note') {
return { route: '/projects', sectionId: 'project-notes', projectId: domain.id ?? undefined };
}
return { route: '/projects', projectId: domain.id ?? undefined };
}
case 'node':
if (!domain.id) return null;
return { route: '/projects', sectionId: domain.id, nodeId: domain.id };
default:
return null;
}
}
function FloatingChatInner() {
const { state, sections, close, updatePosition, setPendingSection, moveToSection } = useFloatingChat();
const navigate = useNavigate();
const routerState = useRouterState();
const prevPathRef = useRef(routerState.location.pathname);
const domainNavigationInFlightRef = useRef(false);
// Active section lookup
const activeSection = sections.get(state.activeSectionId ?? '');
// Chat context — floating mode with scope derived from active section
const chatContext = useMemo<UIChatContext>(() => {
const scope = activeSection
? {
type: (activeSection.label?.toLowerCase().includes('task')
? 'task'
: activeSection.label?.toLowerCase().includes('note')
? 'note'
: activeSection.label?.toLowerCase().includes('timeline')
? 'timeline'
: 'project') as 'task' | 'project' | 'note' | 'timeline',
id: activeSection.projectId,
}
: undefined;
return {
type: 'floating' as const,
projectId: activeSection?.projectId,
scope,
};
}, [activeSection?.projectId, activeSection?.label]);
// Handle floating_domain signals — navigate in background
const handleDomainSignal = useCallback(
(domainSignal: FloatingDomainSignal) => {
const target = normalizeDomainSignal(domainSignal);
if (!target) return;
// If backend points to a currently registered node/section, move there immediately.
if (target.sectionId && sections.has(target.sectionId)) {
moveToSection(target.sectionId);
return;
}
const currentPath = routerState.location.pathname;
const isCurrentRoute =
(target.route === '/projects' && currentPath === '/projects') ||
(target.route === '/tasks' && currentPath === '/tasks') ||
(target.route === '/timeline' && currentPath === '/timeline') ||
(target.route === '/notes/$noteId' && currentPath.startsWith('/notes/'));
if (isCurrentRoute && target.sectionId) {
setPendingSection({ sectionId: target.sectionId });
return;
}
if (isCurrentRoute) return;
domainNavigationInFlightRef.current = true;
const pendingSectionId = target.sectionId;
if (pendingSectionId) {
setPendingSection({ sectionId: pendingSectionId });
} else {
setPendingSection(undefined);
}
if (target.route === '/projects') {
void navigate({ to: '/projects', search: target.projectId ? { projectId: target.projectId } : {} });
} else if (target.route === '/notes/$noteId' && target.noteId) {
void navigate({ to: '/notes/$noteId', params: { noteId: target.noteId } });
} else if (target.route === '/tasks') {
void navigate({ to: '/tasks' });
} else if (target.route === '/timeline') {
void navigate({ to: '/timeline' });
}
},
[routerState.location.pathname, navigate, setPendingSection, sections, moveToSection],
);
const {
messages,
isStreaming,
streamingContent,
handleSend,
clearMessages,
cacheKey,
} = useAIChat(chatContext, { onDomainSignal: handleDomainSignal });
const containerRef = useRef<HTMLDivElement>(null);
// ---- Close on Escape ----
useEffect(() => {
if (!state.isOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
close();
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [state.isOpen, close]);
// ---- Close on route change (unless cross-page navigation pending) ----
// Tracks whether the most recent close was triggered by user navigation.
// Used to decide whether to reset the session on close.
const closeByNavigationRef = useRef(false);
useEffect(() => {
const currentPath = routerState.location.pathname;
if (prevPathRef.current !== currentPath && state.isOpen) {
// Keep floating chat alive when navigation is AI-domain driven.
if (domainNavigationInFlightRef.current) {
domainNavigationInFlightRef.current = false;
} else if (!state.pendingSection) {
closeByNavigationRef.current = true;
close();
}
}
prevPathRef.current = currentPath;
}, [routerState.location.pathname, state.isOpen, state.pendingSection, close]);
// ---- Clear messages on close ----
const prevOpenRef = useRef(state.isOpen);
useEffect(() => {
if (prevOpenRef.current && !state.isOpen) {
const resetSession = closeByNavigationRef.current;
closeByNavigationRef.current = false;
// Clear input draft first so the unmount flush writes '' to the cache.
inputRef.current?.clear();
clearMessages(resetSession);
}
prevOpenRef.current = state.isOpen;
}, [state.isOpen, clearMessages]);
// ---- Window resize: keep within bounds ----
useEffect(() => {
if (!state.isOpen) return;
const handler = () => {
// Re-anchor if the container would go offscreen
const el = containerRef.current;
if (el) {
const rect = el.getBoundingClientRect();
if (rect.right > window.innerWidth || rect.bottom > window.innerHeight) {
el.style.left = `${Math.max(PADDING, Math.min(state.position.x, window.innerWidth - getChatWidth() - PADDING))}px`;
el.style.top = `${Math.max(PADDING, Math.min(state.position.y, window.innerHeight - CHAT_HEIGHT - PADDING))}px`;
}
}
};
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, [state.isOpen, state.position.x, state.position.y]);
// ---- Scroll tracking: dual-anchor repositioning ----
useEffect(() => {
if (!state.isOpen || !state.activeSectionId) return;
const section = sections.get(state.activeSectionId);
if (!section || section.anchorMode === 'right-margin') return;
const el = section.ref.current;
if (!el) return;
// Find scrollable ancestor
let scrollParent: HTMLElement | null = el.parentElement;
while (scrollParent) {
const style = getComputedStyle(scrollParent);
if (style.overflow === 'auto' || style.overflow === 'scroll' ||
style.overflowY === 'auto' || style.overflowY === 'scroll') {
break;
}
// Also check for Radix ScrollArea viewport
if (scrollParent.hasAttribute('data-radix-scroll-area-viewport')) break;
scrollParent = scrollParent.parentElement;
}
if (!scrollParent) return;
let rafId: number | null = null;
const handleScroll = () => {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
const newPos = computeDualAnchor(section);
if (newPos) {
updatePosition(newPos);
}
// null = fully off-screen → freeze (do nothing)
});
};
scrollParent.addEventListener('scroll', handleScroll, { passive: true });
return () => {
scrollParent.removeEventListener('scroll', handleScroll);
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, [state.isOpen, state.activeSectionId, sections, updatePosition]);
// ---- Auto-scroll messages ----
const scrollRef = useRef<HTMLDivElement>(null);
const scrollToBottom = useCallback(() => {
const el = scrollRef.current;
if (el) el.scrollTo({ top: el.scrollHeight });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent, scrollToBottom]);
// ---- Auto-focus input on open ----
const inputRef = useRef<ChatInputBoxHandle>(null);
useEffect(() => {
if (state.isOpen) {
const timer = setTimeout(() => inputRef.current?.focus(), 100);
return () => clearTimeout(timer);
}
}, [state.isOpen]);
const hasMessages = messages.length > 0 || isStreaming;
// Expand the messages panel upward if there's enough space above the input bar,
// otherwise expand downward. 320px = 300px max-h + 8px gap + 12px buffer.
const expandUp = state.position.y >= 320;
return (
<AnimatePresence>
{state.isOpen && (
<motion.div
ref={containerRef}
key="floating-chat"
layout
layoutId={state.morphTargetId ?? undefined}
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 12 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
style={{
position: 'fixed',
left: state.position.x,
top: state.position.y,
width: state.position.width,
zIndex: 9999,
}}
className="relative"
>
{/* ---- Messages panel — floats above or below the input bar ---- */}
<AnimatePresence>
{hasMessages && (
<motion.div
key="messages-panel"
initial={{ opacity: 0, scale: 0.97, y: expandUp ? 8 : -8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: expandUp ? 8 : -8 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
style={{
position: 'absolute',
width: '100%',
...(expandUp
? { bottom: 'calc(100% + 8px)' }
: { top: 'calc(100% + 8px)' }),
}}
className="rounded-2xl overflow-hidden"
>
<div
ref={scrollRef}
className="max-h-[300px] overflow-y-auto rounded-2xl [&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/40"
>
<div className="flex flex-col gap-2.5 p-3">
{messages.map((msg) => {
if (msg.role === 'user') {
return (
<div key={msg.id} className="flex justify-end">
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-br-md px-3.5 py-2">
<p className="text-xs whitespace-pre-wrap leading-relaxed text-foreground">
{msg.content}
</p>
</div>
</div>
);
}
if (msg.error) {
return (
<div key={msg.id} className="flex justify-start">
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2 !border-destructive/30">
<p className="text-xs text-destructive whitespace-pre-wrap leading-relaxed">
{msg.content}
</p>
</div>
</div>
);
}
return (
<div key={msg.id} className="flex justify-start">
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2">
<div className="text-xs text-foreground">
<ChatMarkdown content={msg.content} />
</div>
</div>
</div>
);
})}
{/* Streaming */}
{isStreaming && (
<div className="flex justify-start">
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2">
{streamingContent ? (
<div className="text-xs text-foreground">
<ChatMarkdown content={streamingContent} />
</div>
) : (
<div className="space-y-1.5 py-0.5">
<Skeleton className="h-3 w-36" />
<Skeleton className="h-3 w-24" />
</div>
)}
</div>
</div>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* ---- Floating input bar ---- */}
<div className="glass-surface relative rounded-2xl transition-shadow focus-within:shadow-[0_8px_60px_-8px_rgba(0,0,0,0.35)]">
{/* Close button */}
<button
onClick={close}
className="absolute -top-1.5 -right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-muted/90 backdrop-blur-sm border border-border/50 shadow-sm hover:bg-muted transition-colors z-10"
>
<X size={10} />
</button>
<ChatInputBox
ref={inputRef}
variant="floating"
cacheKey={cacheKey}
isStreaming={isStreaming}
onSend={handleSend}
placeholder={`Ask about ${activeSection?.label ?? 'this section'}...`}
/>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
export function FloatingChatPortal() {
return createPortal(<FloatingChatInner />, document.body);
}

View File

@@ -177,7 +177,7 @@ export function TaskBriefChat({ taskId, projectId, initialBriefing, onBriefingRe
message: trimmed,
conversationHistory,
sessionId,
mode: 'floating',
mode: 'contextual',
scope: { type: 'task', id: taskId },
briefMode: true,
briefingContext: briefingText || undefined,

View File

@@ -1,6 +1,12 @@
import { useState, useRef, useMemo } from 'react';
import { Link, useRouterState, useNavigate } from '@tanstack/react-router';
import { useMemo, useRef, useState } from 'react';
import { Link, useRouterState, useNavigate, useLocation } from '@tanstack/react-router';
import { LayoutGroup } from 'framer-motion';
import { ContextualChatProvider, useContextualChat } from '@/context/ContextualChatContext';
import { ContextualSidebar } from '@/components/ai/ContextualSidebar';
import { AdiuvaTriggerButton } from '@/components/ai/AdiuvaTriggerButton';
import { HeaderProvider, useHeader } from '@/context/HeaderContext';
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
import type { PanelSize } from 'react-resizable-panels';
import {
House,
ChartGantt,
@@ -19,7 +25,6 @@ import {
import { cn } from '@/lib/utils';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { useDoubleClickAI } from '@/hooks/useDoubleClickAI';
import { useTheme } from '@/components/theme-provider';
import {
Sidebar,
@@ -61,8 +66,6 @@ import {
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Separator } from '@/components/ui/separator';
import { AIChatPanel } from '@/components/ai/AIChatPanel';
import { FloatingChatPortal } from '@/components/ai/FloatingChat';
import { FloatingChatProvider } from '@/context/FloatingChatContext';
import { ExpandedClientsProvider, useExpandedClients } from '@/context/ExpandedClientsContext';
import { TaskBriefingProvider, useTaskBriefing } from '@/context/TaskBriefingContext';
import { LoginForm } from '@/components/auth/LoginForm';
@@ -76,26 +79,85 @@ const NAV_ITEMS = [
{ to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
] as const;
const SIDEBAR_SIZE_KEY = 'chat.sidebar.size';
const SIDEBAR_SIZE_MIN = 22;
const SIDEBAR_SIZE_MAX = 60;
const SIDEBAR_SIZE_DEFAULT = 38;
function readSidebarSize(): number {
if (typeof window === 'undefined') return SIDEBAR_SIZE_DEFAULT;
const v = window.localStorage.getItem(SIDEBAR_SIZE_KEY);
if (!v) return SIDEBAR_SIZE_DEFAULT;
const n = Number(v);
if (!Number.isFinite(n)) return SIDEBAR_SIZE_DEFAULT;
return Math.max(SIDEBAR_SIZE_MIN, Math.min(SIDEBAR_SIZE_MAX, n));
}
function MainArea({ children }: { children: React.ReactNode }) {
const loc = useLocation();
const isHome = loc.pathname === '/';
const { open } = useContextualChat();
// Read once per mount of the open state. When the user reopens the sidebar
// we want the most recent persisted size, so we key the PanelGroup on
// `open` so it remounts each open/close cycle.
const initialSize = useMemo(() => readSidebarSize(), [open]);
if (isHome || !open) {
return <>{children}</>;
}
return (
<ResizablePanelGroup
key={`sidebar-open-${initialSize}`}
orientation="horizontal"
className="h-full w-full"
>
<ResizablePanel defaultSize={`${100 - initialSize}%`} minSize="30%">
{children}
</ResizablePanel>
<ResizableHandle
withHandle
className="bg-border/40 hover:bg-border/70 transition-colors after:w-3! cursor-col-resize"
/>
<ResizablePanel
defaultSize={`${initialSize}%`}
minSize={`${SIDEBAR_SIZE_MIN}%`}
maxSize={`${SIDEBAR_SIZE_MAX}%`}
onResize={(panelSize: PanelSize) => {
const clamped = Math.max(
SIDEBAR_SIZE_MIN,
Math.min(SIDEBAR_SIZE_MAX, panelSize.asPercentage),
);
window.localStorage.setItem(SIDEBAR_SIZE_KEY, String(clamped));
}}
>
<div className="h-full w-full">
<ContextualSidebar />
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}
interface AppShellProps {
children: React.ReactNode;
}
export function AppShell({ children }: AppShellProps) {
return (
<FloatingChatProvider>
<ExpandedClientsProvider>
<TaskBriefingProvider>
<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>
);
}

View File

@@ -21,13 +21,13 @@ import { type ProjectGroup } from '@/components/timeline/ProjectTimelineBox';
import { TimelineGanttView } from '@/components/timeline/TimelineGanttView';
import { AddEventDialog } from '@/components/timeline/AddEventDialog';
import { EditEventDialog } from '@/components/timeline/EditEventDialog';
import { useFloatingChat } from '@/context/FloatingChatContext';
import { useTimelineHistory } from '@/hooks/useTimelineHistory';
import type { EventSnapshot } from '@/components/timeline/history-types';
import { cn } from '@/lib/utils';
import { ProjectTabBar, SECTIONS, type SectionId } from './ProjectTabBar';
import { FolderChip } from './folder/FolderChip';
import { FilesSection } from './folder/FilesSection';
import { useContextualScope } from '@/hooks/useContextualScope';
type ProjectDetailProps = {
projectId: string;
@@ -59,7 +59,6 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
const didInitialScroll = useRef(false);
const { registerSection, unregisterSection } = useFloatingChat();
const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
const { data: scanStatus } = trpc.projectFolders.getStatus.useQuery(
@@ -87,18 +86,6 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
const clearHistoryRef = useRef(clearHistory);
clearHistoryRef.current = clearHistory;
useEffect(() => {
if (isLoading || !project) return;
registerSection({ id: 'project-summary', label: 'Project Summary', ref: summaryRef, projectId });
registerSection({ id: 'project-tasks', label: 'Tasks', ref: tasksRef, projectId });
registerSection({ id: 'project-notes', label: 'Notes', ref: notesRef, projectId });
return () => {
unregisterSection('project-summary');
unregisterSection('project-tasks');
unregisterSection('project-notes');
};
}, [projectId, isLoading, project, registerSection, unregisterSection]);
// Compact hero on scroll. scrollRef is the definitive scroll container
// (flex-1 min-h-0 overflow-y-auto inside a flex-col parent with min-h-0
// at every ancestor, so h-full never resolves to content-height).
@@ -158,6 +145,20 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
const { data: tasksList } = trpc.tasks.list.useQuery({ projectId });
const { data: eventsList } = trpc.timelineEvents.list.useQuery({ projectId });
useContextualScope({
page: 'project',
entityType: project ? 'project' : null,
entityId: project?.id,
entityName: project?.name,
counts: project
? {
tasks: tasksList?.length ?? 0,
notes: notesList?.length ?? 0,
milestones: (eventsList ?? []).filter((e) => e.type === 'milestone').length,
}
: undefined,
});
const breadcrumbPath = useMemo(() => {
if (!project?.clientId || !clientsList) return [];
const clientMap = new Map(clientsList.map((c) => [c.id, c]));
@@ -467,9 +468,10 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
{subtitle}
</span>
</h1>
<div className="shrink-0 flex items-center gap-2">
<div
className={cn(
'shrink-0 overflow-hidden transition-all duration-200 ease-out',
'overflow-hidden transition-all duration-200 ease-out',
compact ? 'max-w-0 max-h-0 opacity-0' : 'max-w-xs max-h-8 opacity-100',
)}
>
@@ -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">

View File

@@ -55,9 +55,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { useExpandedClients } from '@/context/ExpandedClientsContext';
import { Separator } from '@/components/ui/separator';
import {
Empty,
EmptyContent,
@@ -72,9 +70,11 @@ const NO_CLIENT_KEY = '__no_client__';
type ProjectSidebarProps = {
selectedProjectId: string | undefined;
onSelectProject: (id: string) => void;
newProjectOpen: boolean;
setNewProjectOpen: (open: boolean) => void;
};
export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSidebarProps) {
export function ProjectSidebar({ selectedProjectId, onSelectProject, newProjectOpen, setNewProjectOpen }: ProjectSidebarProps) {
const utils = trpc.useUtils();
const [showArchived, setShowArchived] = useState(false);
@@ -96,8 +96,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
const [editCreatingSubClient, setEditCreatingSubClient] = useState(false);
const [editNewSubClientName, setEditNewSubClientName] = useState('');
// New-project dialog state
const [newProjectOpen, setNewProjectOpen] = useState(false);
// New-project dialog state (open state is lifted to projects.tsx — see newProjectOpen prop)
const [newProjectName, setNewProjectName] = useState('');
const [newProjectClientId, setNewProjectClientId] = useState<string>(NO_CLIENT_KEY);
const [newProjectSubClientId, setNewProjectSubClientId] = useState<string>(NO_CLIENT_KEY);
@@ -108,6 +107,19 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
const [creatingSubClient, setCreatingSubClient] = useState(false);
const [newSubClientName, setNewSubClientName] = useState('');
// Reset form fields whenever the dialog opens (whether triggered from header or empty-state button)
useEffect(() => {
if (newProjectOpen) {
setNewProjectName('');
setNewProjectClientId(NO_CLIENT_KEY);
setNewProjectSubClientId(NO_CLIENT_KEY);
setCreatingClient(false);
setNewClientName('');
setCreatingSubClient(false);
setNewSubClientName('');
}
}, [newProjectOpen]);
const { data: projectList = [] } = trpc.projects.list.useQuery(
{ includeArchived: showArchived },
);
@@ -265,13 +277,6 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
}
function handleOpenNewProject() {
setNewProjectName('');
setNewProjectClientId(NO_CLIENT_KEY);
setNewProjectSubClientId(NO_CLIENT_KEY);
setCreatingClient(false);
setNewClientName('');
setCreatingSubClient(false);
setNewSubClientName('');
setNewProjectOpen(true);
}
@@ -410,25 +415,8 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
return (
<div className="flex flex-col h-full min-h-0 border-r border-border w-60 shrink-0">
{/* Header */}
<div className="flex h-14 items-center gap-2 px-3 shrink-0">
<SidebarTrigger />
<Separator orientation="vertical" className="data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px mr-2 data-[orientation=vertical]:h-4" />
<h4 className="text-sm font-medium text-foreground flex-1">{t('projects.projects')}</h4>
<Button
variant="outline"
size="icon"
className="size-7"
onClick={handleOpenNewProject}
disabled={createMutation.isPending}
aria-label={t('projects.newProject')}
>
<Plus />
</Button>
</div>
{/* Search */}
<div className="px-3 pb-2 shrink-0">
<div className="px-3 pt-2 pb-2 shrink-0">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input

View File

@@ -9,7 +9,7 @@ export type SectionId = typeof SECTIONS[number];
interface ProjectTabBarProps {
sectionRefs: Record<SectionId, RefObject<HTMLDivElement | null>>;
scrollRef: RefObject<HTMLDivElement | null>;
heroRef: RefObject<HTMLDivElement | null>;
heroRef?: RefObject<HTMLDivElement | null>;
initialTab?: string;
}
@@ -20,10 +20,23 @@ export function ProjectTabBar({ sectionRefs, scrollRef, heroRef, initialTab }: P
(SECTIONS.includes(initialTab as SectionId) ? initialTab : 'overview') as SectionId,
);
// Live hero height — kept in state so both the sticky top offset and the
// IntersectionObserver rootMargin update automatically when the hero resizes
// (compact ↔ expanded transition) or when it first appears after data loads.
const [heroH, setHeroH] = useState(0);
useEffect(() => {
const el = heroRef?.current;
if (!el) return;
const measure = () => setHeroH(el.getBoundingClientRect().height);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [heroRef]);
useEffect(() => {
const root = scrollRef.current;
if (!root) return;
const heroH = heroRef.current?.getBoundingClientRect().height ?? 88;
const tabBarH = 41;
const visible = new Map<SectionId, IntersectionObserverEntry>();
const observer = new IntersectionObserver(
@@ -56,7 +69,7 @@ export function ProjectTabBar({ sectionRefs, scrollRef, heroRef, initialTab }: P
if (ref.current) observer.observe(ref.current);
}
return () => observer.disconnect();
}, [sectionRefs, scrollRef, heroRef]);
}, [sectionRefs, scrollRef, heroH]);
const scrollToSection = useCallback((id: SectionId) => {
const el = scrollRef.current;
@@ -66,17 +79,18 @@ export function ProjectTabBar({ sectionRefs, scrollRef, heroRef, initialTab }: P
} else {
const ref = sectionRefs[id];
if (!ref?.current) return;
const heroH = heroRef.current?.getBoundingClientRect().height ?? 88;
const currentHeroH = heroRef?.current?.getBoundingClientRect().height ?? heroH;
const sectionTop = ref.current.getBoundingClientRect().top;
const containerTop = el.getBoundingClientRect().top;
const top = el.scrollTop + sectionTop - containerTop - heroH - 41;
const top = el.scrollTop + sectionTop - containerTop - currentHeroH - 41;
el.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
}
void navigate({
search: (prev: Record<string, string | undefined>) => ({ ...prev, tab: id }),
replace: true,
});
}, [sectionRefs, scrollRef, heroRef, navigate]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sectionRefs, scrollRef, heroRef, heroH, navigate]);
const TAB_LABELS: Record<SectionId, string> = {
overview: t('projects.overview'),
@@ -89,7 +103,7 @@ export function ProjectTabBar({ sectionRefs, scrollRef, heroRef, initialTab }: P
return (
<nav
className="sticky z-20 backdrop-blur-md border-b border-border/40"
style={{ top: 'var(--hero-h)' }}
style={{ top: heroH }}
>
<div className="mx-auto max-w-6xl px-8 flex gap-0">
{SECTIONS.map((id) => (

View File

@@ -28,7 +28,6 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { ProjectTimeline, GANTT_LABEL_WIDTH, type TimelineEvent } from './ProjectTimeline';
import { TimelineAxisHeader, HEADER_HEIGHT } from './TimelineAxisHeader';
import { type ProjectGroup } from './ProjectTimelineBox';
import { useFloatingChat } from '@/context/FloatingChatContext';
type ZoomLevel = 'day' | 'week' | 'month';
const COLUMN_PX = 32;
@@ -55,8 +54,6 @@ export interface TimelineGanttViewProps {
onEdit: (ev: TimelineEvent) => void;
onDuplicate: (ev: TimelineEvent) => void;
onMove: (id: string, date: number, endDate: number | null) => void;
sectionId: string;
sectionLabel: string;
projectId?: string;
className?: string;
}
@@ -75,8 +72,6 @@ function TimelineGanttViewInner({
onEdit,
onDuplicate,
onMove,
sectionId,
sectionLabel,
projectId,
className,
}: TimelineGanttViewProps) {
@@ -92,8 +87,6 @@ function TimelineGanttViewInner({
const { data: savedZoom } = trpc.settings.getTimelineZoom.useQuery();
const saveZoom = trpc.settings.setTimelineZoom.useMutation();
const { registerSection, unregisterSection } = useFloatingChat();
useEffect(() => {
if (savedZoom && savedZoom !== zoomLevel) setZoomLevel(savedZoom as ZoomLevel);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -104,11 +97,6 @@ function TimelineGanttViewInner({
saveZoom.mutate({ level });
}
useEffect(() => {
registerSection({ id: sectionId, label: sectionLabel, ref: sectionRef, projectId });
return () => unregisterSection(sectionId);
}, [sectionId, sectionLabel, projectId, registerSection, unregisterSection]);
useEffect(() => {
const el = scrollContainerRef.current;
if (!el) return;
@@ -236,7 +224,6 @@ function TimelineGanttViewInner({
return (
<div
ref={sectionRef}
data-ai-section={sectionId}
className={cn('@container flex flex-col gap-4 w-full', className)}
>
{/* Header: Legend + Actions */}

View File

@@ -0,0 +1,279 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { trpc } from '@/lib/trpc';
import type { ChatMessage } from '@/hooks/useAIChat';
export interface ContextualScope {
page: 'timeline' | 'tasks' | 'projects-list' | 'project' | 'note';
entityType?: 'project' | 'note' | null;
entityId?: string;
entityName?: string;
projectId?: string | null;
counts?: { tasks?: number; notes?: number; milestones?: number };
charCount?: number;
filters?: unknown;
}
interface ContextualChatState {
open: boolean;
size: number;
sessionId: string | null;
scope: ContextualScope | null;
messages: ChatMessage[];
isStreaming: boolean;
streamingContent: string;
toggle: () => void;
close: () => void;
newChat: () => Promise<void>;
setSize: (s: number) => void;
setScope: (s: ContextualScope) => void;
send: (text: string) => void;
}
const Ctx = createContext<ContextualChatState | null>(null);
const SESSION_KEY = 'chat.contextual.lastSessionId';
const SIZE_KEY = 'chat.sidebar.size';
const OPEN_KEY = 'chat.contextual.open';
const SIZE_MIN = 22;
const SIZE_MAX = 60;
const SIZE_DEFAULT = 38;
function clamp(n: number, min: number, max: number): number {
return Math.max(min, Math.min(max, n));
}
function readNumber(k: string, fallback: number): number {
if (typeof window === 'undefined') return fallback;
const v = window.localStorage.getItem(k);
if (!v) return fallback;
const n = Number(v);
if (!Number.isFinite(n)) return fallback;
// Defensive: clamp stale or out-of-range persisted values.
return clamp(n, SIZE_MIN, SIZE_MAX);
}
export function ContextualChatProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState<boolean>(() =>
typeof window !== 'undefined' && window.localStorage.getItem(OPEN_KEY) === '1',
);
const [size, setSizeState] = useState<number>(() => readNumber(SIZE_KEY, SIZE_DEFAULT));
const [sessionId, setSessionId] = useState<string | null>(() =>
typeof window !== 'undefined' ? window.localStorage.getItem(SESSION_KEY) : null,
);
const [scope, setScopeState] = useState<ContextualScope | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const streamRef = useRef('');
const activeUnsubRef = useRef<(() => void) | null>(null);
const utils = trpc.useUtils();
const createSession = trpc.aiChat.createSession.useMutation();
const appendMessage = trpc.aiChat.appendMessage.useMutation();
const chatMutation = trpc.ai.chat.useMutation();
// Hydrate or create session on mount. One-shot effect.
const hydratedRef = useRef(false);
useEffect(() => {
if (hydratedRef.current) return;
hydratedRef.current = true;
let cancelled = false;
(async () => {
if (!sessionId) {
const { id } = await createSession.mutateAsync({ channel: 'contextual' });
if (cancelled) return;
window.localStorage.setItem(SESSION_KEY, id);
setSessionId(id);
} else {
const res = await utils.aiChat.getSession.fetch({ id: sessionId });
if (cancelled) return;
if (!res) {
const { id } = await createSession.mutateAsync({ channel: 'contextual' });
if (cancelled) return;
window.localStorage.setItem(SESSION_KEY, id);
setSessionId(id);
} else if (res.messages) {
setMessages(
res.messages
.filter((m) => m.role !== 'system')
.map((m) => ({
id: m.id,
role: m.role as 'user' | 'assistant',
content: m.content,
})),
);
}
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const setSize = useCallback((s: number) => {
const clamped = clamp(s, SIZE_MIN, SIZE_MAX);
setSizeState(clamped);
window.localStorage.setItem(SIZE_KEY, String(clamped));
}, []);
const toggle = useCallback(() => {
setOpen((o) => {
const next = !o;
window.localStorage.setItem(OPEN_KEY, next ? '1' : '0');
return next;
});
}, []);
const close = useCallback(() => {
setOpen(false);
window.localStorage.setItem(OPEN_KEY, '0');
}, []);
const newChat = useCallback(async () => {
const { id } = await createSession.mutateAsync({ channel: 'contextual' });
window.localStorage.setItem(SESSION_KEY, id);
setSessionId(id);
setMessages([]);
}, [createSession]);
const lastScopeKeyRef = useRef<string>('');
const setScope = useCallback(
(s: ContextualScope) => {
const key = JSON.stringify(s);
if (key === lastScopeKeyRef.current) return;
lastScopeKeyRef.current = key;
setScopeState(s);
if (sessionId && (window as any).electronAI?.sendContextualScopeUpdate) {
// Best-effort fire — exposed by preload in M4.7.
(window as any).electronAI.sendContextualScopeUpdate({ sessionId, scope: s });
}
},
[sessionId],
);
const send = useCallback(
(text: string) => {
if (!sessionId || !scope) return;
const trimmed = text.trim();
if (!trimmed || isStreaming) return;
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content: trimmed,
};
setMessages((prev) => [...prev, userMsg]);
appendMessage.mutate({
sessionId,
role: 'user',
content: trimmed,
scope: JSON.stringify(scope),
});
const requestId = crypto.randomUUID();
setIsStreaming(true);
setStreamingContent('');
streamRef.current = '';
const unsub = window.electronAI.onStreamEvent((event) => {
if (event.requestId !== requestId) return;
switch (event.type) {
case 'stream_text':
streamRef.current += event.chunk;
setStreamingContent(streamRef.current);
break;
case 'stream_end': {
const final = streamRef.current;
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: final },
]);
appendMessage.mutate({
sessionId,
role: 'assistant',
content: final,
scope: JSON.stringify(scope),
});
setStreamingContent('');
streamRef.current = '';
setIsStreaming(false);
unsub();
activeUnsubRef.current = null;
break;
}
}
});
activeUnsubRef.current = unsub;
chatMutation.mutate(
{
requestId,
message: trimmed,
conversationHistory: messages.slice(-20).map((m) => ({
role: m.role,
content: m.content,
})),
sessionId,
mode: 'contextual',
scope,
},
{
onError: (err) => {
unsub();
activeUnsubRef.current = null;
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: 'assistant',
content: err.message || 'An unexpected error occurred.',
error: true,
},
]);
setStreamingContent('');
streamRef.current = '';
setIsStreaming(false);
},
},
);
},
[sessionId, scope, isStreaming, messages, appendMessage, chatMutation],
);
// Unmount cleanup: unsubscribe any in-flight stream listener.
useEffect(() => {
return () => {
activeUnsubRef.current?.();
activeUnsubRef.current = null;
};
}, []);
const value = useMemo<ContextualChatState>(
() => ({
open,
size,
sessionId,
scope,
messages,
isStreaming,
streamingContent,
toggle,
close,
newChat,
setSize,
setScope,
send,
}),
[open, size, sessionId, scope, messages, isStreaming, streamingContent, toggle, close, newChat, setSize, setScope, send],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useContextualChat() {
const v = useContext(Ctx);
if (!v) throw new Error('useContextualChat must be used within ContextualChatProvider');
return v;
}

View File

@@ -1,262 +0,0 @@
import {
createContext,
useContext,
useCallback,
useState,
useRef,
type ReactNode,
type RefObject,
} from 'react';
// ---------- Types ----------
interface AISection {
id: string; // e.g. "project-tasks", "tasks-list", "timeline-chart"
label: string; // Human-readable, e.g. "Tasks", "Project Timeline"
ref: RefObject<HTMLElement | null>;
projectId?: string; // If section is project-scoped
anchorMode?: 'top-right' | 'right-margin'; // default: 'top-right'
}
interface SectionOpenOpts {
clickY?: number; // For right-margin mode: Y-coordinate of the double-click
}
interface FloatingChatState {
isOpen: boolean;
activeSectionId: string | null;
position: { x: number; y: number; width: number };
morphTargetId: string | null;
projectId?: string;
pendingSection?: { sectionId: string; clickY?: number }; // For cross-page navigation
}
interface FloatingChatContextValue {
// State
state: FloatingChatState;
sections: Map<string, AISection>;
// Section registry
registerSection: (section: AISection) => void;
unregisterSection: (id: string) => void;
// Actions
openAtSection: (sectionId: string, opts?: SectionOpenOpts) => void;
moveToSection: (sectionId: string, opts?: SectionOpenOpts) => void;
close: () => void;
setMorphTarget: (id: string | null) => void;
updatePosition: (pos: { x: number; y: number; width: number }) => void;
setPendingSection: (pending: { sectionId: string; clickY?: number } | undefined) => void;
}
// ---------- Constants ----------
/** Dynamic chat width: 35% of viewport, clamped between 320px and 520px. */
export function getChatWidth(): number {
return Math.min(630, Math.max(320, Math.round(window.innerWidth * 0.35)));
}
export const CHAT_HEIGHT = 420;
export const PADDING = 16;
// ---------- Position computation ----------
function clampPosition(x: number, y: number): { x: number; y: number } {
const w = getChatWidth();
return {
x: Math.max(PADDING, Math.min(x, window.innerWidth - w - PADDING)),
y: Math.max(PADDING, Math.min(y, window.innerHeight - CHAT_HEIGHT - PADDING)),
};
}
function computeAnchorPosition(
section: AISection,
opts?: SectionOpenOpts,
): { x: number; y: number; width: number } {
const el = section.ref.current;
const w = getChatWidth();
if (!el) return { x: PADDING, y: PADDING, width: w };
const rect = el.getBoundingClientRect();
const mode = section.anchorMode ?? 'top-right';
if (mode === 'right-margin') {
// Position to the right of the section at the click Y-coordinate
const rawX = rect.right + PADDING;
const rawY = opts?.clickY ?? rect.top + PADDING;
const { x, y } = clampPosition(rawX, rawY);
return { x, y, width: w };
}
// Default: top-right of section
const rawX = rect.right - w - PADDING;
const rawY = rect.top + PADDING;
const { x, y } = clampPosition(rawX, rawY);
return { x, y, width: w };
}
/**
* Dual-anchor recomputation for scroll tracking.
* Returns null when the section is fully off-screen (freeze at last position).
*/
export function computeDualAnchor(
section: AISection,
): { x: number; y: number; width: number } | null {
const el = section.ref.current;
if (!el) return null;
// Skip scroll tracking for right-margin mode (stays at fixed clickY)
if (section.anchorMode === 'right-margin') return null;
const rect = el.getBoundingClientRect();
const w = getChatWidth();
// Fully off-screen — freeze
if (rect.bottom < 0 || rect.top > window.innerHeight) return null;
// Primary anchor: top-right (when section top is visible)
if (rect.top >= PADDING) {
const { x, y } = clampPosition(
rect.right - w - PADDING,
rect.top + PADDING,
);
return { x, y, width: w };
}
// Fallback anchor: bottom-right (when section top scrolled off)
if (rect.bottom > CHAT_HEIGHT) {
const { x, y } = clampPosition(
rect.right - w - PADDING,
rect.bottom - CHAT_HEIGHT - PADDING,
);
return { x, y, width: w };
}
// Section visible but too small for fallback — clamp to top
const { x, y } = clampPosition(
rect.right - w - PADDING,
PADDING,
);
return { x, y, width: w };
}
// ---------- Context ----------
const FloatingChatCtx = createContext<FloatingChatContextValue | null>(null);
export function useFloatingChat(): FloatingChatContextValue {
const ctx = useContext(FloatingChatCtx);
if (!ctx)
throw new Error('useFloatingChat must be used within FloatingChatProvider');
return ctx;
}
// ---------- Provider ----------
export function FloatingChatProvider({ children }: { children: ReactNode }) {
const sectionsRef = useRef<Map<string, AISection>>(new Map());
const [sections, setSections] = useState<Map<string, AISection>>(new Map());
const [state, setState] = useState<FloatingChatState>({
isOpen: false,
activeSectionId: null,
position: { x: 0, y: 0, width: getChatWidth() },
morphTargetId: null,
});
const registerSection = useCallback((section: AISection) => {
sectionsRef.current.set(section.id, section);
setSections(new Map(sectionsRef.current));
// Check if there's a pending section to open after cross-page navigation
setState((prev) => {
if (prev.pendingSection && prev.pendingSection.sectionId === section.id) {
const position = computeAnchorPosition(section, { clickY: prev.pendingSection.clickY });
return {
...prev,
isOpen: true,
activeSectionId: section.id,
position,
morphTargetId: null,
projectId: section.projectId,
pendingSection: undefined,
};
}
return prev;
});
}, []);
const unregisterSection = useCallback((id: string) => {
sectionsRef.current.delete(id);
setSections(new Map(sectionsRef.current));
}, []);
const openAtSection = useCallback((sectionId: string, opts?: SectionOpenOpts) => {
const section = sectionsRef.current.get(sectionId);
if (!section) return;
const position = computeAnchorPosition(section, opts);
setState({
isOpen: true,
activeSectionId: sectionId,
position,
morphTargetId: null,
projectId: section.projectId,
});
}, []);
const moveToSection = useCallback((sectionId: string, opts?: SectionOpenOpts) => {
const section = sectionsRef.current.get(sectionId);
if (!section) return;
const position = computeAnchorPosition(section, opts);
setState((prev) => ({
...prev,
activeSectionId: sectionId,
position,
projectId: section.projectId,
}));
}, []);
const close = useCallback(() => {
setState((prev) => ({
...prev,
isOpen: false,
activeSectionId: null,
morphTargetId: null,
}));
}, []);
const setMorphTarget = useCallback((id: string | null) => {
setState((prev) => ({ ...prev, morphTargetId: id }));
}, []);
const updatePosition = useCallback((pos: { x: number; y: number; width: number }) => {
setState((prev) => ({ ...prev, position: pos }));
}, []);
const setPendingSection = useCallback((pending: { sectionId: string; clickY?: number } | undefined) => {
setState((prev) => ({ ...prev, pendingSection: pending }));
}, []);
return (
<FloatingChatCtx.Provider
value={{
state,
sections,
registerSection,
unregisterSection,
openAtSection,
moveToSection,
close,
setMorphTarget,
updatePosition,
setPendingSection,
}}
>
{children}
</FloatingChatCtx.Provider>
);
}

View File

@@ -0,0 +1,40 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
interface HeaderContextValue {
label: string | null;
extras: ReactNode;
/** Replaces the page-label slot. When set, no h4 is rendered. */
leftExtras: ReactNode;
/** Rendered between the flex-1 spacer and the AdiuvaTriggerButton. */
rightExtras: ReactNode;
setLabel: (label: string | null) => void;
setExtras: (extras: ReactNode) => void;
setLeftExtras: (extras: ReactNode) => void;
setRightExtras: (extras: ReactNode) => void;
}
const HeaderContext = createContext<HeaderContextValue | null>(null);
export function HeaderProvider({ children }: { children: ReactNode }) {
const [label, setLabelState] = useState<string | null>(null);
const [extras, setExtrasState] = useState<ReactNode>(null);
const [leftExtras, setLeftExtrasState] = useState<ReactNode>(null);
const [rightExtras, setRightExtrasState] = useState<ReactNode>(null);
const setLabel = useCallback((l: string | null) => setLabelState(l), []);
const setExtras = useCallback((e: ReactNode) => setExtrasState(e), []);
const setLeftExtras = useCallback((e: ReactNode) => setLeftExtrasState(e), []);
const setRightExtras = useCallback((e: ReactNode) => setRightExtrasState(e), []);
return (
<HeaderContext.Provider value={{ label, extras, leftExtras, rightExtras, setLabel, setExtras, setLeftExtras, setRightExtras }}>
{children}
</HeaderContext.Provider>
);
}
export function useHeader() {
const ctx = useContext(HeaderContext);
if (!ctx) throw new Error('useHeader must be used inside HeaderProvider');
return ctx;
}

View File

@@ -365,3 +365,78 @@ body {
--crepe-shadow-1: 0px 1px 2px 0px rgba(255, 255, 255, 0.3), 0px 1px 3px 1px rgba(255, 255, 255, 0.15);
--crepe-shadow-2: 0px 1px 2px 0px rgba(255, 255, 255, 0.3), 0px 2px 6px 2px rgba(255, 255, 255, 0.15);
}
/* ---------------------------------------------------------------------------
* Adiuva trigger button + compass icon
* --------------------------------------------------------------------------- */
.adiuva-btn {
position: relative;
width: 48px;
height: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
/* Light mode: pure white pops on pinkish canvas (#f4edf3). */
background: #ffffff;
border: 1px solid #c8c3cd;
border-radius: 14px;
cursor: pointer;
transition: box-shadow .25s ease, background .2s ease, border-color .2s ease;
/* Centered ambient shadow, not bottom-weighted. */
box-shadow:
0 0 0 1px rgba(0, 0, 0, .02),
0 2px 8px -2px rgba(0, 0, 0, .08),
0 6px 16px -4px rgba(0, 0, 0, .06);
color: inherit;
padding: 0;
}
.adiuva-btn:hover {
background: #ffffff;
border-color: color-mix(in srgb, #c8c3cd 50%, #fbc881 50%);
box-shadow:
0 0 0 1px rgba(251, 200, 129, .25),
0 2px 10px -2px rgba(0, 0, 0, .10),
0 8px 22px -4px rgba(251, 200, 129, .22);
}
.adiuva-btn:active { transform: scale(.97); }
.adiuva-btn.sm { width: 40px; height: 40px; border-radius: 12px; }
/* Dark mode: surface needs to lift off near-black canvas (#0c0c0c). */
.dark .adiuva-btn {
background: #1f1f22;
border-color: rgba(255, 255, 255, .08);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, .05),
0 0 0 1px rgba(0, 0, 0, .35),
0 4px 14px -2px rgba(0, 0, 0, .55),
0 10px 24px -6px rgba(0, 0, 0, .45);
}
.dark .adiuva-btn:hover {
background: #26262a;
border-color: rgba(251, 200, 129, .18);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, .06),
0 0 0 1px rgba(251, 200, 129, .12),
0 4px 18px -2px rgba(0, 0, 0, .6),
0 12px 28px -8px rgba(251, 200, 129, .25);
}
/* The asset SVG already animates internally — img tag uses its own keyframes.
These external keyframes remain only for the older inline-SVG path (kept
for back-compat if any consumer still uses <AdiuvaIcon> inline). */
.adiuva-needle-g {
transform-origin: 32px 32px;
animation: adiuva-compass-settle 6s ease-in-out infinite;
}
@keyframes adiuva-compass-settle {
0% { transform: rotate(0deg); }
20% { transform: rotate(4deg); }
50% { transform: rotate(-3deg); }
80% { transform: rotate(2deg); }
100% { transform: rotate(0deg); }
}
@media (prefers-reduced-motion: reduce) {
.adiuva-needle-g { animation: none; }
.adiuva-mark-img { animation: none; }
}

View File

@@ -1,33 +1,16 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { trpc } from '@/lib/trpc';
export type FloatingDomainSignal =
| 'tasks'
| 'notes'
| 'timelines'
| 'projects'
| {
type: 'task' | 'timeline' | 'project' | 'note' | 'node';
id?: string | null;
section?: 'task' | 'timeline' | 'note' | null;
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* Renderer-only context describing where the user is in the UI.
* Retained for call-site compatibility; mode/scope fields support v3 routing.
*/
export interface UIChatContext {
type: 'global' | 'project' | 'floating';
type: 'global' | 'project';
projectId?: string;
/** For floating mode — the entity scope to pass to the backend. */
scope?: {
type: 'task' | 'project' | 'note' | 'timeline';
id?: string;
};
}
export interface ChatMessage {
@@ -47,10 +30,6 @@ interface UseAIChatReturn {
cacheKey: string;
}
interface UseAIChatOptions {
onDomainSignal?: (domain: FloatingDomainSignal) => void;
}
interface CachedChatState {
messages: ChatMessage[];
/** Written by ChatInputBox; read on mount to restore draft. Not written by this hook. */
@@ -62,11 +41,7 @@ const chatSessionCache = new Map<string, CachedChatState>();
function getContextCacheKey(ctx: UIChatContext): string {
if (ctx.type === 'global') return 'global';
if (ctx.type === 'project') return `project:${ctx.projectId ?? ''}`;
// Floating chat should keep a single continuous session while the panel is open,
// even when route/section context changes due floating-domain navigation.
return 'floating';
return `project:${ctx.projectId ?? ''}`;
}
// ---------------------------------------------------------------------------
@@ -98,7 +73,7 @@ const TABLE_TO_ENTITY: Record<string, 'task' | 'project' | 'note' | 'timeline'>
timelineEvents: 'timeline',
};
function parseMutationsToEntityTags(mutations: unknown[] | undefined): string {
export function parseMutationsToEntityTags(mutations: unknown[] | undefined): string {
if (!Array.isArray(mutations)) return '';
const tags: string[] = [];
for (const m of mutations) {
@@ -121,10 +96,10 @@ function parseMutationsToEntityTags(mutations: unknown[] | undefined): string {
// Hook
// ---------------------------------------------------------------------------
export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOptions): UseAIChatReturn {
export function useAIChat(defaultContext: UIChatContext): UseAIChatReturn {
const contextCacheKey = useMemo(
() => getContextCacheKey(defaultContext),
[defaultContext.type, defaultContext.projectId, defaultContext.scope?.type, defaultContext.scope?.id],
[defaultContext.type, defaultContext.projectId],
);
const [messages, setMessages] = useState<ChatMessage[]>(
@@ -148,9 +123,6 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
messagesRef.current = messages;
const sessionIdRef = useRef(sessionId);
sessionIdRef.current = sessionId;
const onDomainSignalRef = useRef(options?.onDomainSignal);
onDomainSignalRef.current = options?.onDomainSignal;
// Keep local state aligned when the chat context changes in-place.
useEffect(() => {
const cached = chatSessionCache.get(contextCacheKey);
@@ -234,9 +206,6 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
break;
}
case 'floating_domain':
onDomainSignalRef.current?.(event.domain);
break;
}
});
@@ -246,17 +215,12 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
content: m.content,
}));
const isFloating = ctx.type === 'floating';
chatMutationRef.current.mutate(
{
requestId,
message: trimmed,
conversationHistory,
sessionId: sessionIdRef.current,
...(isFloating && ctx.scope
? { mode: 'floating' as const, scope: ctx.scope }
: {}),
},
{
onSuccess: (data) => {

View File

@@ -0,0 +1,97 @@
import { useCallback, useRef, useState } from 'react';
import { trpc } from '@/lib/trpc';
import type { ChatMessage } from './useAIChat';
import { parseMutationsToEntityTags } from './useAIChat';
export type ChatStreamMode =
| { kind: 'home' }
| { kind: 'project'; projectId?: string }
| { kind: 'contextual'; scope: unknown };
export interface UseChatStreamArgs {
sessionId: string;
/** Called when the full assistant turn has been assembled. */
onAssistantMessage: (msg: ChatMessage) => void;
/** Called when the request fails. */
onError: (msg: ChatMessage) => void;
}
export function useChatStream({
sessionId,
onAssistantMessage,
onError,
}: UseChatStreamArgs) {
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const ref = useRef('');
const mutation = trpc.ai.chat.useMutation();
const mutationRef = useRef(mutation);
mutationRef.current = mutation;
const send = useCallback(
(args: {
message: string;
history: { role: 'user' | 'assistant'; content: string }[];
mode: ChatStreamMode;
}) => {
if (isStreaming) return;
setIsStreaming(true);
setStreamingContent('');
ref.current = '';
const requestId = crypto.randomUUID();
const unsubscribe = window.electronAI.onStreamEvent((event) => {
if (event.requestId !== requestId) return;
switch (event.type) {
case 'stream_start':
break;
case 'stream_text':
ref.current += event.chunk;
setStreamingContent(ref.current);
break;
case 'stream_end': {
const mutationTags = parseMutationsToEntityTags(event.mutations);
onAssistantMessage({
id: crypto.randomUUID(),
role: 'assistant',
content: ref.current + mutationTags,
});
setStreamingContent('');
ref.current = '';
setIsStreaming(false);
unsubscribe();
break;
}
}
});
const input: Record<string, unknown> = {
requestId,
message: args.message,
conversationHistory: args.history,
sessionId,
};
if (args.mode.kind === 'contextual') {
input.mode = 'contextual';
input.scope = args.mode.scope;
}
mutationRef.current.mutate(input as never, {
onError: (err) => {
unsubscribe();
onError({
id: crypto.randomUUID(),
role: 'assistant',
content: err.message || 'An unexpected error occurred.',
error: true,
});
setStreamingContent('');
ref.current = '';
setIsStreaming(false);
},
});
},
[sessionId, onAssistantMessage, onError, isStreaming],
);
return { send, isStreaming, streamingContent };
}

View File

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

View File

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

View File

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

View File

@@ -16,25 +16,13 @@ interface ElectronTRPC {
type V3StreamEvent =
| { type: 'stream_start'; requestId: string }
| { type: 'stream_text'; requestId: string; chunk: string }
| { type: 'stream_end'; requestId: string; mutations?: unknown[] }
| {
type: 'floating_domain';
requestId: string;
domain:
| 'tasks'
| 'notes'
| 'timelines'
| 'projects'
| {
type: 'task' | 'timeline' | 'project' | 'note' | 'node';
id?: string | null;
section?: 'task' | 'timeline' | 'note' | null;
};
};
| { type: 'stream_end'; requestId: string; mutations?: unknown[] };
interface ElectronAI {
onStreamEvent: (cb: (data: V3StreamEvent) => void) => () => void;
onBriefUpdated: (cb: (content: string) => void) => () => void;
/** Exposed by preload in M4.7. Best-effort fire when renderer scope changes. */
sendContextualScopeUpdate?: (args: { sessionId: string; scope: unknown }) => void;
}
interface ElectronDialog {

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { createFileRoute } from '@tanstack/react-router';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Archive, ArchiveX } from 'lucide-react';
import { useContextualScope } from '@/hooks/useContextualScope';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { Switch } from '@/components/ui/switch';
@@ -22,6 +23,7 @@ export const Route = createFileRoute('/timeline')({
function TimelinePage() {
const { t } = useTranslation();
useContextualScope({ page: 'timeline' });
const [dialogOpen, setDialogOpen] = useState(false);
const [editingEvent, setEditingEvent] = useState<TimelineEvent | null>(null);
const [showArchived, setShowArchived] = useState(false);
@@ -231,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

View File

@@ -55,6 +55,7 @@ export const ToolCallActionSchema = z.enum([
'read_project_folder_manifest',
'read_project_folder_file',
'list_projects_with_folder_manifests',
'get_page_details',
]);
export type ToolCallAction = z.infer<typeof ToolCallActionSchema>;
@@ -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,