feat: implement full context-scoped AI chat UI in AIChatPanel

- Added AIChatPanel component with context header, user and AI message handling.
- Integrated streaming responses via IPC and error handling for chat mutations.
- Enhanced user experience with input handling and auto-scrolling features.
- Updated AppShell to derive AI chat context from the current route.
- Introduced ScrollArea component for better scrolling behavior in various dialogs.
- Added support for Tailwind typography and improved global styles.
- Updated project and task dialogs to utilize ScrollArea for better UX.
This commit is contained in:
Roberto Musso
2026-02-24 12:02:06 +01:00
parent 00a43e0fbc
commit 5eb19e022e
20 changed files with 962 additions and 91 deletions

View File

@@ -3,31 +3,43 @@
*
* Wraps the CopilotClient's session API so it can be used as a drop-in
* BaseChatModel within LangGraph, making the orchestrator provider-agnostic.
*
* Accepts a client-getter function to avoid module duplication issues when
* this file is code-split into a separate chunk by Vite.
*/
import { SimpleChatModel, type BaseChatModelCallOptions } from '@langchain/core/language_models/chat_models';
import type { BaseMessage } from '@langchain/core/messages';
import { AIMessageChunk } from '@langchain/core/messages';
import { ChatGenerationChunk } from '@langchain/core/outputs';
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
import { getCopilotClient } from './copilot';
type CopilotClientType = import('@github/copilot-sdk').CopilotClient;
const COPILOT_TIMEOUT = 60_000;
export class ChatCopilot extends SimpleChatModel<BaseChatModelCallOptions> {
constructor() {
private getClient: () => CopilotClientType | null;
constructor(getClient: () => CopilotClientType | null) {
super({});
this.getClient = getClient;
}
_llmType(): string {
return 'copilot';
}
private requireClient(): CopilotClientType {
const client = this.getClient();
if (!client) {
throw new Error('CopilotClient not initialized. Please check that Copilot CLI is authenticated (copilot auth login).');
}
return client;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async _call(messages: BaseMessage[], _options: this['ParsedCallOptions'], _runManager?: CallbackManagerForLLMRun): Promise<string> {
const client = getCopilotClient();
if (!client) {
throw new Error('CopilotClient not initialized. Please add your GitHub token in Settings.');
}
const client = this.requireClient();
// Extract system message and user prompt from LangChain messages
const systemContent = messages
@@ -61,10 +73,7 @@ export class ChatCopilot extends SimpleChatModel<BaseChatModelCallOptions> {
_options: this['ParsedCallOptions'],
_runManager?: CallbackManagerForLLMRun,
): AsyncGenerator<ChatGenerationChunk> {
const client = getCopilotClient();
if (!client) {
throw new Error('CopilotClient not initialized. Please add your GitHub token in Settings.');
}
const client = this.requireClient();
const systemContent = messages
.filter((m) => m._getType() === 'system')

View File

@@ -5,14 +5,14 @@ import { registerProvider, type AIProvider } from './provider';
type CopilotClientType = import('@github/copilot-sdk').CopilotClient;
let client: CopilotClientType | null = null;
let token: string | null = null;
let isReady = false;
const copilotProvider: AIProvider = {
name: 'copilot',
displayName: 'GitHub Copilot',
usesExternalAuth: true,
async initialize(t: string): Promise<boolean> {
token = t;
async initialize(): Promise<boolean> {
try {
// Stop existing client if re-initializing
if (client) {
@@ -22,32 +22,30 @@ const copilotProvider: AIProvider = {
}
const { CopilotClient } = await import('@github/copilot-sdk');
// No githubToken — uses stored OAuth credentials from Copilot CLI
// (authenticate first with `copilot auth login`)
client = new CopilotClient({
githubToken: t,
autoStart: true,
autoRestart: true,
logLevel: 'warning',
});
await client.start();
console.log('[AI] CopilotClient started successfully');
isReady = true;
console.log('[AI] CopilotClient started (using CLI OAuth credentials)');
return true;
} catch (err) {
console.error('[AI] Failed to start CopilotClient:', err);
client = null;
isReady = false;
return false;
}
},
isReady(): boolean {
return client !== null && token !== null;
return isReady && client !== null;
},
};
/** Get the raw Copilot token (used by future chat/completion calls). */
export function getCopilotToken(): string | null {
return token;
}
/** Get the CopilotClient instance (null if not initialized). */
export function getCopilotClient(): CopilotClientType | null {
return client;

View File

@@ -5,8 +5,9 @@
* the only place that knows how to create provider-specific LLM instances.
*/
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { getActiveProviderName } from './provider';
import { getActiveProviderName, getActiveProvider } from './provider';
import { getToken } from './token';
import { getCopilotClient } from './copilot';
// ---------------------------------------------------------------------------
// Provider-specific factory functions (lazy-loaded)
@@ -36,8 +37,10 @@ async function createAnthropicModel(token: string): Promise<BaseChatModel> {
async function createCopilotModel(_token: string): Promise<BaseChatModel> {
// GitHub Copilot uses the Copilot SDK subprocess for auth and API access.
// We wrap it in a LangChain-compatible adapter.
// Pass getCopilotClient from this chunk (same as copilot.ts) to avoid
// module duplication when chat-copilot.ts is code-split by Vite.
const { ChatCopilot } = await import('./chat-copilot');
return new ChatCopilot();
return new ChatCopilot(getCopilotClient);
}
// ---------------------------------------------------------------------------
@@ -62,14 +65,15 @@ export async function getLLM(): Promise<BaseChatModel | null> {
return null;
}
const token = await getToken(providerName);
if (!token) {
const provider = getActiveProvider();
const token = provider?.usesExternalAuth ? '' : await getToken(providerName);
if (!provider?.usesExternalAuth && !token) {
console.log(`[AI] No token available for provider "${providerName}"`);
return null;
}
try {
return await factory(token);
return await factory(token ?? '');
} catch (err) {
console.error(`[AI] Failed to create LLM for "${providerName}":`, err);
return null;

View File

@@ -6,7 +6,6 @@
*/
import { Annotation, StateGraph, START, END } from '@langchain/langgraph';
import { SystemMessage, HumanMessage, type BaseMessage } from '@langchain/core/messages';
import { z } from 'zod';
import { eq, asc } from 'drizzle-orm';
import { getDb } from '../db';
import { projects, tasks, checkpoints, notes, clients } from '../db/schema';
@@ -178,12 +177,6 @@ If the user asks about specific note contents that aren't included here, let the
// LangGraph State
// ---------------------------------------------------------------------------
const RouteSchema = z.object({
route: z.enum(['project', 'knowledge', 'general']).describe(
'Which specialist agent should handle this request',
),
});
const OrchestratorState = Annotation.Root({
/** The user's original message */
userMessage: Annotation<string>(),
@@ -207,25 +200,29 @@ type State = typeof OrchestratorState.State;
// Graph nodes
// ---------------------------------------------------------------------------
/** Node 1: Classify intent using structured output */
/** Node 1: Classify intent using plain-text extraction (works with all providers) */
async function classifyIntent(state: State): Promise<Partial<State>> {
const llm = await getLLM();
if (!llm) throw new Error('AI provider not configured. Please add your token in Settings.');
const routerLLM = llm.withStructuredOutput(RouteSchema);
const result = await routerLLM.invoke([
const response = await llm.invoke([
new SystemMessage(
`You are a routing classifier for Adiuva, a project management workspace.
Classify the user's message into one of these categories:
- "project": Question about a specific project (tasks, notes, checkpoints, progress, summaries)
- "knowledge": Cross-project or historical question (e.g., "what did we decide about X?", "find notes about Y")
- "general": Everything else (general help, scheduling, task overviews, workspace summaries)`,
Classify the user's message into exactly one category. Reply with ONLY the category name, nothing else.
Categories:
- project: Question about a specific project (tasks, notes, checkpoints, progress, summaries)
- knowledge: Cross-project or historical question (e.g., "what did we decide about X?", "find notes about Y")
- general: Everything else (general help, scheduling, task overviews, workspace summaries)`,
),
new HumanMessage(state.userMessage),
]);
return { route: result.route };
const text = (typeof response.content === 'string' ? response.content : '').trim().toLowerCase();
const validRoutes = ['project', 'knowledge', 'general'] as const;
const route = validRoutes.find((r) => text.includes(r)) ?? 'general';
return { route };
}
/** Node 2a: Project agent — answer project-scoped questions */

View File

@@ -10,6 +10,8 @@ export interface AIProvider {
initialize(token: string): Promise<boolean>;
/** Whether the provider is initialized and ready to handle requests. */
isReady(): boolean;
/** If true, this provider uses external auth (e.g. CLI OAuth) and doesn't need a stored token. */
usesExternalAuth?: boolean;
}
const providers = new Map<string, AIProvider>();
@@ -49,9 +51,12 @@ export async function saveTokenAndInit(token: string): Promise<void> {
}
}
/** Check whether the active provider has a stored token. */
/** Check whether the active provider has credentials (stored token or external auth). */
export async function hasActiveToken(): Promise<boolean> {
const name = getActiveProviderName();
const provider = providers.get(name);
// Providers with external auth (e.g. Copilot CLI OAuth) don't need a stored token
if (provider?.usesExternalAuth) return true;
const token = await getToken(name);
return token !== null && token.length > 0;
}
@@ -69,6 +74,14 @@ export async function initAI(): Promise<void> {
return;
}
// Providers with external auth (e.g. Copilot CLI OAuth) initialize without a stored token
if (provider.usesExternalAuth) {
const ready = await provider.initialize('');
activeProvider = provider;
console.log(`[AI] Provider "${provider.displayName}" initialized (external auth): ready=${ready}`);
return;
}
const token = await getToken(name);
if (token) {
const ready = await provider.initialize(token);