Compare commits

..

1 Commits

Author SHA1 Message Date
66dd3ee995 chore: add agent column to TASKS.md schema 2026-03-19 20:07:25 -05:00
38 changed files with 378 additions and 6081 deletions

View File

@@ -62,15 +62,9 @@ OTEL_SERVICE_NAME=mosaic-gateway
# Comma-separated list of Ollama model IDs to register (default: llama3.2,codellama,mistral) # Comma-separated list of Ollama model IDs to register (default: llama3.2,codellama,mistral)
# OLLAMA_MODELS=llama3.2,codellama,mistral # OLLAMA_MODELS=llama3.2,codellama,mistral
# Anthropic (claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5) # OpenAI — required for embedding and log-summarization features
# ANTHROPIC_API_KEY=sk-ant-...
# OpenAI (gpt-4o, gpt-4o-mini, o3-mini)
# OPENAI_API_KEY=sk-... # OPENAI_API_KEY=sk-...
# Z.ai / GLM (glm-4.5, glm-4.5-air, glm-4.5-flash)
# ZAI_API_KEY=...
# Custom providers — JSON array of provider configs # Custom providers — JSON array of provider configs
# Format: [{"id":"<id>","baseUrl":"<url>","apiKey":"<key>","models":[{"id":"<model-id>","name":"<label>"}]}] # Format: [{"id":"<id>","baseUrl":"<url>","apiKey":"<key>","models":[{"id":"<model-id>","name":"<label>"}]}]
# MOSAIC_CUSTOM_PROVIDERS= # MOSAIC_CUSTOM_PROVIDERS=
@@ -129,26 +123,7 @@ OTEL_SERVICE_NAME=mosaic-gateway
# TELEGRAM_GATEWAY_URL=http://localhost:4000 # TELEGRAM_GATEWAY_URL=http://localhost:4000
# ─── SSO Providers (add credentials to enable) ─────────────────────────────── # ─── Authentik SSO (optional — set AUTHENTIK_CLIENT_ID to enable) ────────────
# --- Authentik (optional — set AUTHENTIK_CLIENT_ID to enable) ---
# AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic/ # AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic/
# AUTHENTIK_CLIENT_ID= # AUTHENTIK_CLIENT_ID=
# AUTHENTIK_CLIENT_SECRET= # AUTHENTIK_CLIENT_SECRET=
# --- WorkOS (optional — set WORKOS_CLIENT_ID to enable) ---
# WORKOS_ISSUER=https://your-company.authkit.app
# WORKOS_CLIENT_ID=client_...
# WORKOS_CLIENT_SECRET=sk_live_...
# --- Keycloak (optional — set KEYCLOAK_CLIENT_ID to enable) ---
# KEYCLOAK_ISSUER=https://auth.example.com/realms/master
# Legacy alternative if you prefer to compose the issuer from separate vars:
# KEYCLOAK_URL=https://auth.example.com
# KEYCLOAK_REALM=master
# KEYCLOAK_CLIENT_ID=mosaic
# KEYCLOAK_CLIENT_SECRET=
# Feature flags — set to true alongside provider credentials to show SSO buttons in the UI
# NEXT_PUBLIC_WORKOS_ENABLED=true
# NEXT_PUBLIC_KEYCLOAK_ENABLED=true

View File

@@ -53,28 +53,3 @@ pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
- ESM everywhere (`"type": "module"`, `.js` extensions in imports) - ESM everywhere (`"type": "module"`, `.js` extensions in imports)
- NodeNext module resolution in all tsconfigs - NodeNext module resolution in all tsconfigs
- Scratchpads are mandatory for non-trivial tasks - Scratchpads are mandatory for non-trivial tasks
## docs/TASKS.md — Schema (CANONICAL)
The `agent` column specifies the required model for each task. **This is set at task creation by the orchestrator and must not be changed by workers.**
| Value | When to use | Budget |
| -------- | ----------------------------------------------------------- | -------------------------- |
| `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred |
| `glm-5` | Cost-sensitive coding where Codex is unavailable | Z.ai credits |
| `haiku` | Review gates, verify tasks, status checks, docs-only | Cheapest Claude tier |
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |
| `—` | No preference / auto-select cheapest capable | Pipeline decides |
Pipeline crons read this column and spawn accordingly. Workers never modify `docs/TASKS.md` — only the orchestrator writes it.
**Full schema:**
```
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
```
- `status`: `not-started` | `in-progress` | `done` | `failed` | `blocked` | `needs-qa`
- `agent`: model value from table above (set before spawning)
- `estimate`: token budget e.g. `8K`, `25K`

View File

@@ -1,143 +0,0 @@
import { beforeEach, afterEach, describe, expect, it } from 'vitest';
import { ProviderService } from '../provider.service.js';
const ENV_KEYS = [
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
'ZAI_API_KEY',
'OLLAMA_BASE_URL',
'OLLAMA_HOST',
'OLLAMA_MODELS',
'MOSAIC_CUSTOM_PROVIDERS',
] as const;
type EnvKey = (typeof ENV_KEYS)[number];
describe('ProviderService', () => {
const savedEnv = new Map<EnvKey, string | undefined>();
beforeEach(() => {
for (const key of ENV_KEYS) {
savedEnv.set(key, process.env[key]);
delete process.env[key];
}
});
afterEach(() => {
for (const key of ENV_KEYS) {
const value = savedEnv.get(key);
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
it('skips API-key providers when env vars are missing (no models become available)', () => {
const service = new ProviderService();
service.onModuleInit();
// Pi's built-in registry may include model definitions for all providers, but
// without API keys none of them should be available (usable).
const availableModels = service.listAvailableModels();
const availableProviderIds = new Set(availableModels.map((m) => m.provider));
expect(availableProviderIds).not.toContain('anthropic');
expect(availableProviderIds).not.toContain('openai');
expect(availableProviderIds).not.toContain('zai');
// Providers list may show built-in providers, but they should not be marked available
const providers = service.listProviders();
for (const p of providers.filter((p) => ['anthropic', 'openai', 'zai'].includes(p.id))) {
expect(p.available).toBe(false);
}
});
it('registers Anthropic provider with correct models when ANTHROPIC_API_KEY is set', () => {
process.env['ANTHROPIC_API_KEY'] = 'test-anthropic';
const service = new ProviderService();
service.onModuleInit();
const providers = service.listProviders();
const anthropic = providers.find((p) => p.id === 'anthropic');
expect(anthropic).toBeDefined();
expect(anthropic!.available).toBe(true);
expect(anthropic!.models.map((m) => m.id)).toEqual([
'claude-sonnet-4-6',
'claude-opus-4-6',
'claude-haiku-4-5',
]);
// contextWindow override from Pi built-in (200000)
for (const m of anthropic!.models) {
expect(m.contextWindow).toBe(200000);
// maxTokens capped at 8192 per task spec
expect(m.maxTokens).toBe(8192);
}
});
it('registers OpenAI provider with correct models when OPENAI_API_KEY is set', () => {
process.env['OPENAI_API_KEY'] = 'test-openai';
const service = new ProviderService();
service.onModuleInit();
const providers = service.listProviders();
const openai = providers.find((p) => p.id === 'openai');
expect(openai).toBeDefined();
expect(openai!.available).toBe(true);
expect(openai!.models.map((m) => m.id)).toEqual(['gpt-4o', 'gpt-4o-mini', 'o3-mini']);
});
it('registers Z.ai provider with correct models when ZAI_API_KEY is set', () => {
process.env['ZAI_API_KEY'] = 'test-zai';
const service = new ProviderService();
service.onModuleInit();
const providers = service.listProviders();
const zai = providers.find((p) => p.id === 'zai');
expect(zai).toBeDefined();
expect(zai!.available).toBe(true);
expect(zai!.models.map((m) => m.id)).toEqual(['glm-4.5', 'glm-4.5-air', 'glm-4.5-flash']);
});
it('registers all three providers when all keys are set', () => {
process.env['ANTHROPIC_API_KEY'] = 'test-anthropic';
process.env['OPENAI_API_KEY'] = 'test-openai';
process.env['ZAI_API_KEY'] = 'test-zai';
const service = new ProviderService();
service.onModuleInit();
const providerIds = service.listProviders().map((p) => p.id);
expect(providerIds).toContain('anthropic');
expect(providerIds).toContain('openai');
expect(providerIds).toContain('zai');
});
it('can find registered Anthropic models by provider+id', () => {
process.env['ANTHROPIC_API_KEY'] = 'test-anthropic';
const service = new ProviderService();
service.onModuleInit();
const sonnet = service.findModel('anthropic', 'claude-sonnet-4-6');
expect(sonnet).toBeDefined();
expect(sonnet!.provider).toBe('anthropic');
expect(sonnet!.id).toBe('claude-sonnet-4-6');
});
it('can find registered Z.ai models by provider+id', () => {
process.env['ZAI_API_KEY'] = 'test-zai';
const service = new ProviderService();
service.onModuleInit();
const glm = service.findModel('zai', 'glm-4.5');
expect(glm).toBeDefined();
expect(glm!.provider).toBe('zai');
expect(glm!.id).toBe('glm-4.5');
});
});

View File

@@ -1,6 +1,6 @@
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common'; import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { ModelRegistry, AuthStorage } from '@mariozechner/pi-coding-agent'; import { ModelRegistry, AuthStorage } from '@mariozechner/pi-coding-agent';
import { getModel, type Model, type Api } from '@mariozechner/pi-ai'; import type { Model, Api } from '@mariozechner/pi-ai';
import type { ModelInfo, ProviderInfo, CustomProviderConfig } from '@mosaic/types'; import type { ModelInfo, ProviderInfo, CustomProviderConfig } from '@mosaic/types';
import type { TestConnectionResultDto } from './provider.dto.js'; import type { TestConnectionResultDto } from './provider.dto.js';
@@ -14,9 +14,6 @@ export class ProviderService implements OnModuleInit {
this.registry = new ModelRegistry(authStorage); this.registry = new ModelRegistry(authStorage);
this.registerOllamaProvider(); this.registerOllamaProvider();
this.registerAnthropicProvider();
this.registerOpenAIProvider();
this.registerZaiProvider();
this.registerCustomProviders(); this.registerCustomProviders();
const available = this.registry.getAvailable(); const available = this.registry.getAvailable();
@@ -143,66 +140,6 @@ export class ProviderService implements OnModuleInit {
this.logger.log(`Registered custom provider: ${config.id} (${config.models.length} models)`); this.logger.log(`Registered custom provider: ${config.id} (${config.models.length} models)`);
} }
private registerAnthropicProvider(): void {
const apiKey = process.env['ANTHROPIC_API_KEY'];
if (!apiKey) {
this.logger.debug('Skipping Anthropic provider registration: ANTHROPIC_API_KEY not set');
return;
}
const models = ['claude-sonnet-4-6', 'claude-opus-4-6', 'claude-haiku-4-5'].map((id) =>
this.cloneBuiltInModel('anthropic', id, { maxTokens: 8192 }),
);
this.registry.registerProvider('anthropic', {
apiKey,
baseUrl: 'https://api.anthropic.com',
models,
});
this.logger.log('Anthropic provider registered with 3 models');
}
private registerOpenAIProvider(): void {
const apiKey = process.env['OPENAI_API_KEY'];
if (!apiKey) {
this.logger.debug('Skipping OpenAI provider registration: OPENAI_API_KEY not set');
return;
}
const models = ['gpt-4o', 'gpt-4o-mini', 'o3-mini'].map((id) =>
this.cloneBuiltInModel('openai', id),
);
this.registry.registerProvider('openai', {
apiKey,
baseUrl: 'https://api.openai.com/v1',
models,
});
this.logger.log('OpenAI provider registered with 3 models');
}
private registerZaiProvider(): void {
const apiKey = process.env['ZAI_API_KEY'];
if (!apiKey) {
this.logger.debug('Skipping Z.ai provider registration: ZAI_API_KEY not set');
return;
}
const models = ['glm-4.5', 'glm-4.5-air', 'glm-4.5-flash'].map((id) =>
this.cloneBuiltInModel('zai', id),
);
this.registry.registerProvider('zai', {
apiKey,
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
models,
});
this.logger.log('Z.ai provider registered with 3 models');
}
private registerOllamaProvider(): void { private registerOllamaProvider(): void {
const ollamaUrl = process.env['OLLAMA_BASE_URL'] ?? process.env['OLLAMA_HOST']; const ollamaUrl = process.env['OLLAMA_BASE_URL'] ?? process.env['OLLAMA_HOST'];
if (!ollamaUrl) return; if (!ollamaUrl) return;
@@ -247,19 +184,6 @@ export class ProviderService implements OnModuleInit {
} }
} }
private cloneBuiltInModel(
provider: string,
modelId: string,
overrides: Partial<Model<Api>> = {},
): Model<Api> {
const model = getModel(provider as never, modelId as never) as Model<Api> | undefined;
if (!model) {
throw new Error(`Built-in model not found: ${provider}:${modelId}`);
}
return { ...model, ...overrides };
}
private toModelInfo(model: Model<Api>): ModelInfo { private toModelInfo(model: Model<Api>): ModelInfo {
return { return {
id: model.id, id: model.id,

View File

@@ -36,24 +36,16 @@ export class SessionGCService implements OnModuleInit {
@Inject(LOG_SERVICE) private readonly logService: LogService, @Inject(LOG_SERVICE) private readonly logService: LogService,
) {} ) {}
onModuleInit(): void { async onModuleInit(): Promise<void> {
// Fire-and-forget: run full GC asynchronously so it does not block the this.logger.log('Running full GC on cold start...');
// NestJS bootstrap chain. Cold-start GC typically takes 100500 ms const result = await this.fullCollect();
// depending on Valkey key count; deferring it removes that latency from this.logger.log(
// the TTFB of the first HTTP request. `Full GC complete: ${result.valkeyKeys} Valkey keys, ` +
this.fullCollect() `${result.logsDemoted} logs demoted, ` +
.then((result) => { `${result.jobsPurged} jobs purged, ` +
this.logger.log( `${result.tempFilesRemoved} temp dirs removed ` +
`Full GC complete: ${result.valkeyKeys} Valkey keys, ` + `(${result.duration}ms)`,
`${result.logsDemoted} logs demoted, ` + );
`${result.jobsPurged} jobs purged, ` +
`${result.tempFilesRemoved} temp dirs removed ` +
`(${result.duration}ms)`,
);
})
.catch((err: unknown) => {
this.logger.error('Cold-start GC failed', err instanceof Error ? err.stack : String(err));
});
} }
/** /**

View File

@@ -5,28 +5,34 @@ import type { Db } from '@mosaic/db';
/** /**
* Build a mock Drizzle DB where the select chain supports: * Build a mock Drizzle DB where the select chain supports:
* db.select().from().where() → resolves to `listRows` * db.select().from().where() → resolves to `listRows`
* db.insert().values().onConflictDoUpdate() → resolves to [] * db.select().from().where().limit(n) → resolves to `singleRow`
*/ */
function makeMockDb(listRows: Array<{ key: string; value: unknown }> = []): Db { function makeMockDb(
listRows: Array<{ key: string; value: unknown }> = [],
singleRow: Array<{ id: string }> = [],
): Db {
const chainWithLimit = { const chainWithLimit = {
limit: vi.fn().mockResolvedValue([]), limit: vi.fn().mockResolvedValue(singleRow),
then: (resolve: (v: typeof listRows) => unknown) => Promise.resolve(listRows).then(resolve), then: (resolve: (v: typeof listRows) => unknown) => Promise.resolve(listRows).then(resolve),
}; };
const selectFrom = { const selectFrom = {
from: vi.fn().mockReturnThis(), from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnValue(chainWithLimit), where: vi.fn().mockReturnValue(chainWithLimit),
}; };
const updateResult = {
set: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([]),
};
const deleteResult = { const deleteResult = {
where: vi.fn().mockResolvedValue([]), where: vi.fn().mockResolvedValue([]),
}; };
// Single-round-trip upsert chain: insert().values().onConflictDoUpdate()
const insertResult = { const insertResult = {
values: vi.fn().mockReturnThis(), values: vi.fn().mockResolvedValue([]),
onConflictDoUpdate: vi.fn().mockResolvedValue([]),
}; };
return { return {
select: vi.fn().mockReturnValue(selectFrom), select: vi.fn().mockReturnValue(selectFrom),
update: vi.fn().mockReturnValue(updateResult),
delete: vi.fn().mockReturnValue(deleteResult), delete: vi.fn().mockReturnValue(deleteResult),
insert: vi.fn().mockReturnValue(insertResult), insert: vi.fn().mockReturnValue(insertResult),
} as unknown as Db; } as unknown as Db;
@@ -92,14 +98,23 @@ describe('PreferencesService', () => {
expect(result.message).toContain('platform enforcement'); expect(result.message).toContain('platform enforcement');
}); });
it('upserts a mutable preference and returns success', async () => { it('upserts a mutable preference and returns success — insert path', async () => {
// Single-round-trip INSERT … ON CONFLICT DO UPDATE path. // singleRow=[] → no existing row → insert path
const db = makeMockDb([]); const db = makeMockDb([], []);
const service = new PreferencesService(db); const service = new PreferencesService(db);
const result = await service.set('user-1', 'agent.thinkingLevel', 'high'); const result = await service.set('user-1', 'agent.thinkingLevel', 'high');
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.message).toContain('"agent.thinkingLevel"'); expect(result.message).toContain('"agent.thinkingLevel"');
}); });
it('upserts a mutable preference and returns success — update path', async () => {
// singleRow has an id → existing row → update path
const db = makeMockDb([], [{ id: 'existing-id' }]);
const service = new PreferencesService(db);
const result = await service.set('user-1', 'agent.thinkingLevel', 'low');
expect(result.success).toBe(true);
expect(result.message).toContain('"agent.thinkingLevel"');
});
}); });
describe('reset', () => { describe('reset', () => {

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { eq, and, sql, type Db, preferences as preferencesTable } from '@mosaic/db'; import { eq, and, type Db, preferences as preferencesTable } from '@mosaic/db';
import { DB } from '../database/database.module.js'; import { DB } from '../database/database.module.js';
export const PLATFORM_DEFAULTS: Record<string, unknown> = { export const PLATFORM_DEFAULTS: Record<string, unknown> = {
@@ -88,24 +88,25 @@ export class PreferencesService {
} }
private async upsertPref(userId: string, key: string, value: unknown): Promise<void> { private async upsertPref(userId: string, key: string, value: unknown): Promise<void> {
// Single-round-trip upsert using INSERT … ON CONFLICT DO UPDATE. const existing = await this.db
// Previously this was two queries (SELECT + INSERT/UPDATE), which doubled .select({ id: preferencesTable.id })
// the DB round-trips and introduced a TOCTOU window under concurrent writes. .from(preferencesTable)
await this.db .where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key)))
.insert(preferencesTable) .limit(1);
.values({
if (existing.length > 0) {
await this.db
.update(preferencesTable)
.set({ value: value as never, updatedAt: new Date() })
.where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key)));
} else {
await this.db.insert(preferencesTable).values({
userId, userId,
key, key,
value: value as never, value: value as never,
mutable: true, mutable: true,
})
.onConflictDoUpdate({
target: [preferencesTable.userId, preferencesTable.key],
set: {
value: sql`excluded.value`,
updatedAt: sql`now()`,
},
}); });
}
this.logger.debug(`Upserted preference "${key}" for user ${userId}`); this.logger.debug(`Upserted preference "${key}" for user ${userId}`);
} }

View File

@@ -3,30 +3,6 @@ import type { NextConfig } from 'next';
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', output: 'standalone',
transpilePackages: ['@mosaic/design-tokens'], transpilePackages: ['@mosaic/design-tokens'],
// Enable gzip/brotli compression for all responses.
compress: true,
// Reduce bundle size: disable source maps in production builds.
productionBrowserSourceMaps: false,
// Image optimisation: allow the gateway origin as an external image source.
images: {
formats: ['image/avif', 'image/webp'],
remotePatterns: [
{
protocol: 'https',
hostname: '**',
},
],
},
// Experimental: enable React compiler for automatic memoisation (Next 15+).
// Falls back gracefully if the compiler plugin is not installed.
experimental: {
// Turbopack is the default in dev for Next 15; keep it opt-in for now.
// turbo: {},
},
}; };
export default nextConfig; export default nextConfig;

View File

@@ -18,7 +18,6 @@
"next": "^16.0.0", "next": "^16.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"socket.io-client": "^4.8.0", "socket.io-client": "^4.8.0",
"tailwind-merge": "^3.5.0" "tailwind-merge": "^3.5.0"
}, },

View File

@@ -1,17 +1,14 @@
'use client'; 'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { signIn } from '@/lib/auth-client'; import { signIn } from '@/lib/auth-client';
import { getEnabledSsoProviders } from '@/lib/sso-providers';
export default function LoginPage(): React.ReactElement { export default function LoginPage(): React.ReactElement {
const router = useRouter(); const router = useRouter();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const ssoProviders = getEnabledSsoProviders();
const hasSsoProviders = ssoProviders.length > 0;
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> { async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault(); e.preventDefault();
@@ -47,26 +44,7 @@ export default function LoginPage(): React.ReactElement {
</div> </div>
)} )}
{hasSsoProviders && ( <form className="mt-6 space-y-4" onSubmit={handleSubmit}>
<div className="mt-6 space-y-3">
{ssoProviders.map((provider) => (
<Link
key={provider.id}
href={provider.href}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-surface-border bg-surface-elevated px-4 py-2.5 text-sm font-medium text-text-primary transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card"
>
{provider.buttonLabel}
</Link>
))}
<div className="relative flex items-center">
<div className="flex-1 border-t border-surface-border" />
<span className="mx-3 text-xs text-text-muted">or</span>
<div className="flex-1 border-t border-surface-border" />
</div>
</div>
)}
<form className={hasSsoProviders ? 'space-y-4' : 'mt-6 space-y-4'} onSubmit={handleSubmit}>
<div> <div>
<label htmlFor="email" className="block text-sm font-medium text-text-secondary"> <label htmlFor="email" className="block text-sm font-medium text-text-secondary">
Email Email

View File

@@ -4,42 +4,18 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { destroySocket, getSocket } from '@/lib/socket'; import { destroySocket, getSocket } from '@/lib/socket';
import type { Conversation, Message } from '@/lib/types'; import type { Conversation, Message } from '@/lib/types';
import { import { ConversationList } from '@/components/chat/conversation-list';
ConversationSidebar,
type ConversationSidebarRef,
} from '@/components/chat/conversation-sidebar';
import { MessageBubble } from '@/components/chat/message-bubble'; import { MessageBubble } from '@/components/chat/message-bubble';
import { ChatInput } from '@/components/chat/chat-input'; import { ChatInput } from '@/components/chat/chat-input';
import { StreamingMessage } from '@/components/chat/streaming-message'; import { StreamingMessage } from '@/components/chat/streaming-message';
interface ModelInfo {
id: string;
provider: string;
name: string;
reasoning: boolean;
contextWindow: number;
maxTokens: number;
inputTypes: ('text' | 'image')[];
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
}
interface ProviderInfo {
id: string;
name: string;
available: boolean;
models: ModelInfo[];
}
export default function ChatPage(): React.ReactElement { export default function ChatPage(): React.ReactElement {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [streamingText, setStreamingText] = useState(''); const [streamingText, setStreamingText] = useState('');
const [isStreaming, setIsStreaming] = useState(false); const [isStreaming, setIsStreaming] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [models, setModels] = useState<ModelInfo[]>([]);
const [selectedModelId, setSelectedModelId] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const sidebarRef = useRef<ConversationSidebarRef>(null);
// Track the active conversation ID in a ref so socket event handlers always // Track the active conversation ID in a ref so socket event handlers always
// see the current value without needing to be re-registered. // see the current value without needing to be re-registered.
@@ -50,30 +26,11 @@ export default function ChatPage(): React.ReactElement {
// without stale-closure issues. // without stale-closure issues.
const streamingTextRef = useRef(''); const streamingTextRef = useRef('');
// Load conversations on mount
useEffect(() => { useEffect(() => {
const savedState = window.localStorage.getItem('mosaic-sidebar-open'); api<Conversation[]>('/api/conversations')
if (savedState !== null) { .then(setConversations)
setIsSidebarOpen(savedState === 'true'); .catch(() => {});
}
}, []);
useEffect(() => {
window.localStorage.setItem('mosaic-sidebar-open', String(isSidebarOpen));
}, [isSidebarOpen]);
useEffect(() => {
api<ProviderInfo[]>('/api/providers')
.then((providers) => {
const availableModels = providers
.filter((provider) => provider.available)
.flatMap((provider) => provider.models);
setModels(availableModels);
setSelectedModelId((current) => current || availableModels[0]?.id || '');
})
.catch(() => {
setModels([]);
setSelectedModelId('');
});
}, []); }, []);
// Load messages when active conversation changes // Load messages when active conversation changes
@@ -134,7 +91,6 @@ export default function ChatPage(): React.ReactElement {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}, },
]); ]);
sidebarRef.current?.refresh();
} }
} }
@@ -175,27 +131,58 @@ export default function ChatPage(): React.ReactElement {
}; };
}, []); }, []);
const handleNewConversation = useCallback(async (projectId?: string | null) => { const handleNewConversation = useCallback(async () => {
const conv = await api<Conversation>('/api/conversations', { const conv = await api<Conversation>('/api/conversations', {
method: 'POST', method: 'POST',
body: { title: 'New conversation', projectId: projectId ?? null }, body: { title: 'New conversation' },
}); });
setConversations((prev) => [conv, ...prev]);
sidebarRef.current?.addConversation({
id: conv.id,
title: conv.title,
projectId: conv.projectId,
updatedAt: conv.updatedAt,
archived: conv.archived,
});
setActiveId(conv.id); setActiveId(conv.id);
setMessages([]); setMessages([]);
setIsSidebarOpen(true);
}, []); }, []);
const handleRename = useCallback(async (id: string, title: string) => {
const updated = await api<Conversation>(`/api/conversations/${id}`, {
method: 'PATCH',
body: { title },
});
setConversations((prev) => prev.map((c) => (c.id === id ? updated : c)));
}, []);
const handleDelete = useCallback(
async (id: string) => {
try {
await api<void>(`/api/conversations/${id}`, { method: 'DELETE' });
setConversations((prev) => prev.filter((c) => c.id !== id));
if (activeId === id) {
setActiveId(null);
setMessages([]);
}
} catch (err) {
console.error('[ChatPage] Failed to delete conversation:', err);
}
},
[activeId],
);
const handleArchive = useCallback(
async (id: string, archived: boolean) => {
const updated = await api<Conversation>(`/api/conversations/${id}`, {
method: 'PATCH',
body: { archived },
});
setConversations((prev) => prev.map((c) => (c.id === id ? updated : c)));
// If archiving the active conversation, deselect it
if (archived && activeId === id) {
setActiveId(null);
setMessages([]);
}
},
[activeId],
);
const handleSend = useCallback( const handleSend = useCallback(
async (content: string, options?: { modelId?: string }) => { async (content: string) => {
let convId = activeId; let convId = activeId;
// Auto-create conversation if none selected // Auto-create conversation if none selected
@@ -205,24 +192,25 @@ export default function ChatPage(): React.ReactElement {
method: 'POST', method: 'POST',
body: { title: autoTitle }, body: { title: autoTitle },
}); });
sidebarRef.current?.addConversation({ setConversations((prev) => [conv, ...prev]);
id: conv.id,
title: conv.title,
projectId: conv.projectId,
updatedAt: conv.updatedAt,
archived: conv.archived,
});
setActiveId(conv.id); setActiveId(conv.id);
convId = conv.id; convId = conv.id;
} else if (messages.length === 0) { } else {
// Auto-title the initial placeholder conversation from the first user message. // Auto-title: if the active conversation still has the default "New
const autoTitle = content.slice(0, 60); // conversation" title and this is the first message, update the title
api<Conversation>(`/api/conversations/${convId}`, { // from the message content.
method: 'PATCH', const activeConv = conversations.find((c) => c.id === convId);
body: { title: autoTitle }, if (activeConv?.title === 'New conversation' && messages.length === 0) {
}) const autoTitle = content.slice(0, 60);
.then(() => sidebarRef.current?.refresh()) api<Conversation>(`/api/conversations/${convId}`, {
.catch(() => {}); method: 'PATCH',
body: { title: autoTitle },
})
.then((updated) => {
setConversations((prev) => prev.map((c) => (c.id === convId ? updated : c)));
})
.catch(() => {});
}
} }
// Optimistic user message in local UI state // Optimistic user message in local UI state
@@ -253,67 +241,24 @@ export default function ChatPage(): React.ReactElement {
if (!socket.connected) { if (!socket.connected) {
socket.connect(); socket.connect();
} }
socket.emit('message', { socket.emit('message', { conversationId: convId, content });
conversationId: convId,
content,
modelId: (options?.modelId ?? selectedModelId) || undefined,
});
}, },
[activeId, messages, selectedModelId], [activeId, conversations, messages],
); );
return ( return (
<div <div className="-m-6 flex h-[calc(100vh-3.5rem)]">
className="-m-6 flex h-[calc(100vh-3.5rem)] overflow-hidden" <ConversationList
style={{ background: 'var(--bg-deep, var(--color-surface-bg, #0a0f1a))' }} conversations={conversations}
> activeId={activeId}
<ConversationSidebar onSelect={setActiveId}
ref={sidebarRef} onNew={handleNewConversation}
isOpen={isSidebarOpen} onRename={handleRename}
onClose={() => setIsSidebarOpen(false)} onDelete={handleDelete}
currentConversationId={activeId} onArchive={handleArchive}
onSelectConversation={(conversationId) => {
setActiveId(conversationId);
setMessages([]);
if (conversationId && window.innerWidth < 768) {
setIsSidebarOpen(false);
}
}}
onNewConversation={(projectId) => {
void handleNewConversation(projectId);
}}
/> />
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex flex-1 flex-col">
<div
className="flex items-center gap-3 border-b px-4 py-3"
style={{ borderColor: 'var(--border)' }}
>
<button
type="button"
onClick={() => setIsSidebarOpen((open) => !open)}
className="rounded-lg border p-2 transition-colors"
style={{
borderColor: 'var(--border)',
background: 'var(--surface)',
color: 'var(--text)',
}}
aria-label={isSidebarOpen ? 'Close conversation sidebar' : 'Open conversation sidebar'}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor">
<path strokeWidth="2" strokeLinecap="round" d="M4 7h16M4 12h16M4 17h16" />
</svg>
</button>
<div>
<h1 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
Mosaic Chat
</h1>
<p className="text-xs" style={{ color: 'var(--muted)' }}>
{activeId ? 'Active conversation selected' : 'Choose or start a conversation'}
</p>
</div>
</div>
{activeId ? ( {activeId ? (
<> <>
<div className="flex-1 space-y-4 overflow-y-auto p-6"> <div className="flex-1 space-y-4 overflow-y-auto p-6">
@@ -323,36 +268,19 @@ export default function ChatPage(): React.ReactElement {
{isStreaming && <StreamingMessage text={streamingText} />} {isStreaming && <StreamingMessage text={streamingText} />}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
<ChatInput <ChatInput onSend={handleSend} disabled={isStreaming} />
onSend={handleSend}
isStreaming={isStreaming}
models={models}
selectedModelId={selectedModelId}
onModelChange={setSelectedModelId}
/>
</> </>
) : ( ) : (
<div className="flex flex-1 items-center justify-center px-6"> <div className="flex flex-1 items-center justify-center">
<div <div className="text-center">
className="max-w-md rounded-2xl border px-8 py-10 text-center" <h2 className="text-lg font-medium text-text-secondary">Welcome to Mosaic Chat</h2>
style={{ <p className="mt-1 text-sm text-text-muted">
borderColor: 'var(--border)',
background: 'var(--surface)',
}}
>
<h2 className="text-lg font-medium" style={{ color: 'var(--text)' }}>
Welcome to Mosaic Chat
</h2>
<p className="mt-1 text-sm" style={{ color: 'var(--muted)' }}>
Select a conversation or start a new one Select a conversation or start a new one
</p> </p>
<button <button
type="button" type="button"
onClick={() => { onClick={handleNewConversation}
void handleNewConversation(); className="mt-4 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
}}
className="mt-4 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors"
style={{ background: 'var(--primary)' }}
> >
Start new conversation Start new conversation
</button> </button>

View File

@@ -1,77 +0,0 @@
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useParams, useSearchParams } from 'next/navigation';
import { signIn } from '@/lib/auth-client';
import { getSsoProvider } from '@/lib/sso-providers';
export default function AuthProviderRedirectPage(): React.ReactElement {
const params = useParams<{ provider: string }>();
const searchParams = useSearchParams();
const providerId = typeof params.provider === 'string' ? params.provider : '';
const provider = getSsoProvider(providerId);
const callbackURL = searchParams.get('callbackURL') ?? '/chat';
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const currentProvider = provider;
if (!currentProvider) {
setError('Unknown SSO provider.');
return;
}
if (!currentProvider.enabled) {
setError(`${currentProvider.buttonLabel} is not enabled in this deployment.`);
return;
}
const activeProvider = currentProvider;
let cancelled = false;
async function redirectToProvider(): Promise<void> {
const result = await signIn.oauth2({
providerId: activeProvider.id,
callbackURL,
});
if (!cancelled && result?.error) {
setError(result.error.message ?? `${activeProvider.buttonLabel} sign in failed.`);
}
}
void redirectToProvider();
return () => {
cancelled = true;
};
}, [callbackURL, provider]);
return (
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
<p className="mt-2 text-sm text-text-secondary">
{provider
? `Redirecting you to ${provider.buttonLabel.replace('Continue with ', '')}...`
: 'Preparing your sign-in request...'}
</p>
{error ? (
<div className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error">
<p>{error}</p>
<Link
href="/login"
className="mt-3 inline-block font-medium text-blue-400 hover:text-blue-300"
>
Return to login
</Link>
</div>
) : (
<div className="mt-6 rounded-lg border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-secondary">
If the redirect does not start automatically, return to the login page and try again.
</div>
)}
</div>
);
}

View File

@@ -86,22 +86,6 @@
--spacing-sidebar: 16rem; --spacing-sidebar: 16rem;
} }
:root {
--bg: var(--color-surface-bg);
--bg-deep: var(--color-gray-950);
--surface: var(--color-surface-card);
--surface-2: var(--color-surface-elevated);
--border: var(--color-surface-border);
--text: var(--color-text-primary);
--text-2: var(--color-text-secondary);
--muted: var(--color-text-muted);
--primary: var(--color-blue-500);
--danger: var(--color-error);
--ms-blue-500: var(--color-blue-500);
--sidebar-w: 260px;
--ease: cubic-bezier(0.16, 1, 0.3, 1);
}
/* ─── Base styles ─── */ /* ─── Base styles ─── */
body { body {
background-color: var(--color-surface-bg); background-color: var(--color-surface-bg);

View File

@@ -1,192 +1,52 @@
'use client'; 'use client';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useRef, useState } from 'react';
import type { ModelInfo } from '@/lib/types';
interface ChatInputProps { interface ChatInputProps {
onSend: (content: string, options?: { modelId?: string }) => void; onSend: (content: string) => void;
onStop?: () => void; disabled?: boolean;
isStreaming?: boolean;
models: ModelInfo[];
selectedModelId: string;
onModelChange: (modelId: string) => void;
onRequestEditLastMessage?: () => string | null;
} }
const MAX_HEIGHT = 220; export function ChatInput({ onSend, disabled }: ChatInputProps): React.ReactElement {
export function ChatInput({
onSend,
onStop,
isStreaming = false,
models,
selectedModelId,
onModelChange,
onRequestEditLastMessage,
}: ChatInputProps): React.ReactElement {
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const selectedModel = useMemo(
() => models.find((model) => model.id === selectedModelId) ?? models[0],
[models, selectedModelId],
);
useEffect(() => { function handleSubmit(e: React.FormEvent): void {
const textarea = textareaRef.current; e.preventDefault();
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = `${Math.min(textarea.scrollHeight, MAX_HEIGHT)}px`;
}, [value]);
useEffect(() => {
function handleGlobalFocus(event: KeyboardEvent): void {
if (
(event.metaKey || event.ctrlKey) &&
(event.key === '/' || event.key.toLowerCase() === 'k')
) {
const target = event.target as HTMLElement | null;
if (target?.closest('input, textarea, [contenteditable="true"]')) return;
event.preventDefault();
textareaRef.current?.focus();
}
}
document.addEventListener('keydown', handleGlobalFocus);
return () => document.removeEventListener('keydown', handleGlobalFocus);
}, []);
function handleSubmit(event: React.FormEvent): void {
event.preventDefault();
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed || isStreaming) return; if (!trimmed || disabled) return;
onSend(trimmed, { modelId: selectedModel?.id }); onSend(trimmed);
setValue(''); setValue('');
textareaRef.current?.focus(); textareaRef.current?.focus();
} }
function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>): void { function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>): void {
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { if (e.key === 'Enter' && !e.shiftKey) {
event.preventDefault(); e.preventDefault();
handleSubmit(event); handleSubmit(e);
return;
}
if (event.key === 'ArrowUp' && value.length === 0 && onRequestEditLastMessage) {
const lastMessage = onRequestEditLastMessage();
if (lastMessage) {
event.preventDefault();
setValue(lastMessage);
}
} }
} }
const charCount = value.length;
const tokenEstimate = Math.ceil(charCount / 4);
return ( return (
<form <form onSubmit={handleSubmit} className="border-t border-surface-border bg-surface-card p-4">
onSubmit={handleSubmit} <div className="flex items-end gap-3">
className="border-t px-4 py-4 backdrop-blur-xl md:px-6" <textarea
style={{ ref={textareaRef}
backgroundColor: 'color-mix(in srgb, var(--color-surface) 88%, transparent)', value={value}
borderColor: 'var(--color-border)', onChange={(e) => setValue(e.target.value)}
}} onKeyDown={handleKeyDown}
> disabled={disabled}
<div rows={1}
className="rounded-[28px] border p-3 shadow-[var(--shadow-ms-lg)]" placeholder="Type a message... (Enter to send, Shift+Enter for newline)"
style={{ className="max-h-32 min-h-[2.5rem] flex-1 resize-none rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
backgroundColor: 'var(--color-surface-2)', />
borderColor: 'var(--color-border)', <button
}} type="submit"
> disabled={disabled || !value.trim()}
<div className="mb-3 flex flex-wrap items-center gap-3"> className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
<label className="flex min-w-0 items-center gap-2 text-xs text-[var(--color-muted)]"> >
<span className="uppercase tracking-[0.18em]">Model</span> Send
<select </button>
value={selectedModelId}
onChange={(event) => onModelChange(event.target.value)}
className="rounded-full border px-3 py-1.5 text-sm outline-none"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',
color: 'var(--color-text)',
}}
>
{models.map((model) => (
<option key={`${model.provider}:${model.id}`} value={model.id}>
{model.name} · {model.provider}
</option>
))}
</select>
</label>
<div className="ml-auto hidden items-center gap-2 text-xs text-[var(--color-muted)] md:flex">
<span className="rounded-full border border-[var(--color-border)] px-2 py-1">
/ focus
</span>
<span className="rounded-full border border-[var(--color-border)] px-2 py-1">
K focus
</span>
<span className="rounded-full border border-[var(--color-border)] px-2 py-1">
send
</span>
</div>
</div>
<div className="flex items-end gap-3">
<textarea
ref={textareaRef}
value={value}
onChange={(event) => setValue(event.target.value)}
onKeyDown={handleKeyDown}
disabled={isStreaming}
rows={1}
placeholder="Ask Mosaic something..."
className="min-h-[3.25rem] flex-1 resize-none bg-transparent px-1 py-2 text-sm outline-none placeholder:text-[var(--color-muted)] disabled:opacity-60"
style={{
color: 'var(--color-text)',
maxHeight: `${MAX_HEIGHT}px`,
}}
/>
{isStreaming ? (
<button
type="button"
onClick={onStop}
className="inline-flex h-11 items-center gap-2 rounded-full border px-4 text-sm font-medium transition-colors"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',
color: 'var(--color-text)',
}}
>
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-[var(--color-danger)]" />
Stop
</button>
) : (
<button
type="submit"
disabled={!value.trim()}
className="inline-flex h-11 items-center gap-2 rounded-full px-4 text-sm font-semibold text-white transition-all disabled:cursor-not-allowed disabled:opacity-45"
style={{ backgroundColor: 'var(--color-ms-blue-500)' }}
>
<span>Send</span>
<span aria-hidden="true"></span>
</button>
)}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-[var(--color-muted)]">
<span>{charCount.toLocaleString()} chars</span>
<span>·</span>
<span>~{tokenEstimate.toLocaleString()} tokens</span>
{selectedModel ? (
<>
<span>·</span>
<span>{selectedModel.reasoning ? 'Reasoning on' : 'Fast response'}</span>
</>
) : null}
<span className="ml-auto">Shift+Enter newline · Arrow edit last</span>
</div>
</div> </div>
</form> </form>
); );

View File

@@ -7,8 +7,6 @@ import type { Conversation } from '@/lib/types';
interface ConversationListProps { interface ConversationListProps {
conversations: Conversation[]; conversations: Conversation[];
activeId: string | null; activeId: string | null;
isOpen: boolean;
onClose: () => void;
onSelect: (id: string) => void; onSelect: (id: string) => void;
onNew: () => void; onNew: () => void;
onRename: (id: string, title: string) => void; onRename: (id: string, title: string) => void;
@@ -22,6 +20,7 @@ interface ContextMenuState {
y: number; y: number;
} }
/** Format a date as relative time (e.g. "2h ago", "Yesterday"). */
function formatRelativeTime(dateStr: string): string { function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr); const date = new Date(dateStr);
const now = new Date(); const now = new Date();
@@ -41,8 +40,6 @@ function formatRelativeTime(dateStr: string): string {
export function ConversationList({ export function ConversationList({
conversations, conversations,
activeId, activeId,
isOpen,
onClose,
onSelect, onSelect,
onNew, onNew,
onRename, onRename,
@@ -57,24 +54,24 @@ export function ConversationList({
const [showArchived, setShowArchived] = useState(false); const [showArchived, setShowArchived] = useState(false);
const renameInputRef = useRef<HTMLInputElement>(null); const renameInputRef = useRef<HTMLInputElement>(null);
const activeConversations = conversations.filter((conversation) => !conversation.archived); const activeConversations = conversations.filter((c) => !c.archived);
const archivedConversations = conversations.filter((conversation) => conversation.archived); const archivedConversations = conversations.filter((c) => c.archived);
const filteredActive = searchQuery const filteredActive = searchQuery
? activeConversations.filter((conversation) => ? activeConversations.filter((c) =>
(conversation.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()), (c.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
) )
: activeConversations; : activeConversations;
const filteredArchived = searchQuery const filteredArchived = searchQuery
? archivedConversations.filter((conversation) => ? archivedConversations.filter((c) =>
(conversation.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()), (c.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
) )
: archivedConversations; : archivedConversations;
const handleContextMenu = useCallback((event: React.MouseEvent, conversationId: string) => { const handleContextMenu = useCallback((e: React.MouseEvent, conversationId: string) => {
event.preventDefault(); e.preventDefault();
setContextMenu({ conversationId, x: event.clientX, y: event.clientY }); setContextMenu({ conversationId, x: e.clientX, y: e.clientY });
setDeleteConfirmId(null); setDeleteConfirmId(null);
}, []); }, []);
@@ -100,7 +97,7 @@ export function ConversationList({
} }
setRenamingId(null); setRenamingId(null);
setRenameValue(''); setRenameValue('');
}, [onRename, renameValue, renamingId]); }, [renamingId, renameValue, onRename]);
const cancelRename = useCallback(() => { const cancelRename = useCallback(() => {
setRenamingId(null); setRenamingId(null);
@@ -108,20 +105,24 @@ export function ConversationList({
}, []); }, []);
const handleRenameKeyDown = useCallback( const handleRenameKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => { (e: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') commitRename(); if (e.key === 'Enter') commitRename();
if (event.key === 'Escape') cancelRename(); if (e.key === 'Escape') cancelRename();
}, },
[cancelRename, commitRename], [commitRename, cancelRename],
); );
const handleDeleteClick = useCallback((id: string) => {
setDeleteConfirmId(id);
}, []);
const confirmDelete = useCallback( const confirmDelete = useCallback(
(id: string) => { (id: string) => {
onDelete(id); onDelete(id);
setDeleteConfirmId(null); setDeleteConfirmId(null);
closeContextMenu(); closeContextMenu();
}, },
[closeContextMenu, onDelete], [onDelete, closeContextMenu],
); );
const handleArchiveToggle = useCallback( const handleArchiveToggle = useCallback(
@@ -129,59 +130,47 @@ export function ConversationList({
onArchive(id, archived); onArchive(id, archived);
closeContextMenu(); closeContextMenu();
}, },
[closeContextMenu, onArchive], [onArchive, closeContextMenu],
); );
const contextConversation = contextMenu const contextConv = contextMenu
? conversations.find((conversation) => conversation.id === contextMenu.conversationId) ? conversations.find((c) => c.id === contextMenu.conversationId)
: null; : null;
function renderConversationItem(conversation: Conversation): React.ReactElement { function renderConversationItem(conv: Conversation): React.ReactElement {
const isActive = activeId === conversation.id; const isActive = activeId === conv.id;
const isRenaming = renamingId === conversation.id; const isRenaming = renamingId === conv.id;
return ( return (
<div key={conversation.id} className="group relative"> <div key={conv.id} className="group relative">
{isRenaming ? ( {isRenaming ? (
<div className="px-3 py-2"> <div className="px-3 py-2">
<input <input
ref={renameInputRef} ref={renameInputRef}
value={renameValue} value={renameValue}
onChange={(event) => setRenameValue(event.target.value)} onChange={(e) => setRenameValue(e.target.value)}
onBlur={commitRename} onBlur={commitRename}
onKeyDown={handleRenameKeyDown} onKeyDown={handleRenameKeyDown}
className="w-full rounded-xl border px-3 py-2 text-sm outline-none" className="w-full rounded border border-blue-500 bg-surface-elevated px-2 py-0.5 text-sm text-text-primary outline-none"
style={{
borderColor: 'var(--color-ms-blue-500)',
backgroundColor: 'var(--color-surface-2)',
color: 'var(--color-text)',
}}
maxLength={255} maxLength={255}
/> />
</div> </div>
) : ( ) : (
<button <button
type="button" type="button"
onClick={() => { onClick={() => onSelect(conv.id)}
onSelect(conversation.id); onDoubleClick={() => startRename(conv.id, conv.title)}
if (window.innerWidth < 768) onClose(); onContextMenu={(e) => handleContextMenu(e, conv.id)}
}}
onDoubleClick={() => startRename(conversation.id, conversation.title)}
onContextMenu={(event) => handleContextMenu(event, conversation.id)}
className={cn( className={cn(
'w-full rounded-2xl px-3 py-2 text-left text-sm transition-colors', 'w-full px-3 py-2 text-left text-sm transition-colors',
isActive ? 'shadow-[var(--shadow-ms-sm)]' : 'hover:bg-white/5', isActive
? 'bg-blue-600/20 text-blue-400'
: 'text-text-secondary hover:bg-surface-elevated',
)} )}
style={{
backgroundColor: isActive
? 'color-mix(in srgb, var(--color-ms-blue-500) 22%, transparent)'
: 'transparent',
color: isActive ? 'var(--color-text)' : 'var(--color-text-2)',
}}
> >
<span className="block truncate font-medium">{conversation.title ?? 'Untitled'}</span> <span className="block truncate">{conv.title ?? 'Untitled'}</span>
<span className="block text-xs text-[var(--color-muted)]"> <span className="block text-xs text-text-muted">
{formatRelativeTime(conversation.updatedAt)} {formatRelativeTime(conv.updatedAt)}
</span> </span>
</button> </button>
)} )}
@@ -191,138 +180,127 @@ export function ConversationList({
return ( return (
<> <>
{isOpen ? ( {/* Backdrop to close context menu */}
<button {contextMenu && (
type="button"
className="fixed inset-0 z-20 bg-black/45 md:hidden"
onClick={onClose}
aria-label="Close conversation sidebar"
/>
) : null}
{contextMenu ? (
<div className="fixed inset-0 z-10" onClick={closeContextMenu} aria-hidden="true" /> <div className="fixed inset-0 z-10" onClick={closeContextMenu} aria-hidden="true" />
) : null} )}
<div <div className="flex h-full w-64 flex-col border-r border-surface-border bg-surface-card">
className={cn( {/* Header */}
'fixed inset-y-0 left-0 z-30 flex h-full w-[18.5rem] flex-col border-r px-3 py-3 transition-transform duration-200 md:static md:z-auto', <div className="flex items-center justify-between p-3">
isOpen <h2 className="text-sm font-medium text-text-secondary">Conversations</h2>
? 'translate-x-0'
: '-translate-x-full md:w-0 md:min-w-0 md:overflow-hidden md:border-r-0 md:px-0 md:py-0',
)}
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',
}}
>
<div className="flex items-center justify-between px-1 pb-3">
<h2 className="text-sm font-medium text-[var(--color-text-2)]">Conversations</h2>
<button <button
type="button" type="button"
onClick={onNew} onClick={onNew}
className="rounded-full px-3 py-1 text-xs transition-colors hover:bg-white/5" className="rounded-md px-2 py-1 text-xs text-blue-400 transition-colors hover:bg-surface-elevated"
style={{ color: 'var(--color-ms-blue-400)' }}
> >
+ New + New
</button> </button>
</div> </div>
<div className="pb-3"> {/* Search input */}
<div className="px-3 pb-2">
<input <input
type="search" type="search"
value={searchQuery} value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search conversations" placeholder="Search conversations\u2026"
className="w-full rounded-2xl border px-3 py-2 text-xs placeholder:text-[var(--color-muted)] focus:outline-none" className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-xs text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none"
style={{
backgroundColor: 'var(--color-surface-2)',
borderColor: 'var(--color-border)',
color: 'var(--color-text)',
}}
/> />
</div> </div>
<div className="flex-1 overflow-y-auto space-y-1"> {/* Conversation list */}
{filteredActive.length === 0 && !searchQuery ? ( <div className="flex-1 overflow-y-auto">
<p className="px-1 py-2 text-xs text-[var(--color-muted)]">No conversations yet</p> {filteredActive.length === 0 && !searchQuery && (
) : null} <p className="px-3 py-2 text-xs text-text-muted">No conversations yet</p>
{filteredActive.length === 0 && searchQuery ? ( )}
<p className="px-1 py-2 text-xs text-[var(--color-muted)]"> {filteredActive.length === 0 && searchQuery && (
No results for {searchQuery} <p className="px-3 py-2 text-xs text-text-muted">
No results for &ldquo;{searchQuery}&rdquo;
</p> </p>
) : null} )}
{filteredActive.map((conversation) => renderConversationItem(conversation))} {filteredActive.map((conv) => renderConversationItem(conv))}
{archivedConversations.length > 0 ? ( {/* Archived section */}
<div className="pt-2"> {archivedConversations.length > 0 && (
<div className="mt-2">
<button <button
type="button" type="button"
onClick={() => setShowArchived((prev) => !prev)} onClick={() => setShowArchived((v) => !v)}
className="flex w-full items-center gap-2 px-1 py-1 text-xs text-[var(--color-muted)] transition-colors hover:text-[var(--color-text-2)]" className="flex w-full items-center gap-1 px-3 py-1 text-xs text-text-muted transition-colors hover:text-text-secondary"
> >
<span <span
className={cn('inline-block transition-transform', showArchived && 'rotate-90')} className={cn(
'inline-block transition-transform',
showArchived ? 'rotate-90' : '',
)}
> >
&#9658;
</span> </span>
Archived ({archivedConversations.length}) Archived ({archivedConversations.length})
</button> </button>
{showArchived ? ( {showArchived && (
<div className="mt-1 space-y-1 opacity-70"> <div className="opacity-60">
{filteredArchived.map((conversation) => renderConversationItem(conversation))} {filteredArchived.map((conv) => renderConversationItem(conv))}
</div> </div>
) : null} )}
</div> </div>
) : null} )}
</div> </div>
</div> </div>
{contextMenu && contextConversation ? ( {/* Context menu */}
{contextMenu && contextConv && (
<div <div
className="fixed z-30 min-w-40 rounded-2xl border py-1 shadow-[var(--shadow-ms-lg)]" className="fixed z-20 min-w-36 rounded-md border border-surface-border bg-surface-card py-1 shadow-lg"
style={{ style={{ top: contextMenu.y, left: contextMenu.x }}
top: contextMenu.y,
left: contextMenu.x,
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',
}}
> >
<button <button
type="button" type="button"
className="w-full px-3 py-2 text-left text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5" className="w-full px-3 py-1.5 text-left text-sm text-text-secondary hover:bg-surface-elevated"
onClick={() => startRename(contextConversation.id, contextConversation.title)} onClick={() => startRename(contextConv.id, contextConv.title)}
> >
Rename Rename
</button> </button>
<button <button
type="button" type="button"
className="w-full px-3 py-2 text-left text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5" className="w-full px-3 py-1.5 text-left text-sm text-text-secondary hover:bg-surface-elevated"
onClick={() => onClick={() => handleArchiveToggle(contextConv.id, !contextConv.archived)}
handleArchiveToggle(contextConversation.id, !contextConversation.archived)
}
> >
{contextConversation.archived ? 'Restore' : 'Archive'} {contextConv.archived ? 'Unarchive' : 'Archive'}
</button> </button>
{deleteConfirmId === contextConversation.id ? ( <hr className="my-1 border-surface-border" />
<button {deleteConfirmId === contextConv.id ? (
type="button" <div className="px-3 py-1.5">
className="w-full px-3 py-2 text-left text-sm text-[var(--color-danger)] transition-colors hover:bg-white/5" <p className="mb-1.5 text-xs text-red-400">Delete this conversation?</p>
onClick={() => confirmDelete(contextConversation.id)} <div className="flex gap-2">
> <button
Confirm delete type="button"
</button> className="rounded bg-red-600 px-2 py-0.5 text-xs text-white hover:bg-red-700"
onClick={() => confirmDelete(contextConv.id)}
>
Delete
</button>
<button
type="button"
className="rounded px-2 py-0.5 text-xs text-text-muted hover:bg-surface-elevated"
onClick={() => setDeleteConfirmId(null)}
>
Cancel
</button>
</div>
</div>
) : ( ) : (
<button <button
type="button" type="button"
className="w-full px-3 py-2 text-left text-sm text-[var(--color-danger)] transition-colors hover:bg-white/5" className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-surface-elevated"
onClick={() => setDeleteConfirmId(contextConversation.id)} onClick={() => handleDeleteClick(contextConv.id)}
> >
Delete Delete
</button> </button>
)} )}
</div> </div>
) : null} )}
</> </>
); );
} }

View File

@@ -1,576 +0,0 @@
'use client';
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { api } from '@/lib/api';
import type { Conversation, Project } from '@/lib/types';
export interface ConversationSummary {
id: string;
title: string | null;
projectId: string | null;
updatedAt: string;
archived?: boolean;
}
export interface ConversationSidebarRef {
refresh: () => void;
addConversation: (conversation: ConversationSummary) => void;
}
interface ConversationSidebarProps {
isOpen: boolean;
onClose: () => void;
currentConversationId: string | null;
onSelectConversation: (conversationId: string | null) => void;
onNewConversation: (projectId?: string | null) => void;
}
interface GroupedConversations {
key: string;
label: string;
projectId: string | null;
conversations: ConversationSummary[];
}
function toSummary(conversation: Conversation): ConversationSummary {
return {
id: conversation.id,
title: conversation.title,
projectId: conversation.projectId,
updatedAt: conversation.updatedAt,
archived: conversation.archived,
};
}
function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMinutes = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMs / 3_600_000);
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffMinutes < 1) return 'Just now';
if (diffMinutes < 60) return `${diffMinutes}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
export const ConversationSidebar = forwardRef<ConversationSidebarRef, ConversationSidebarProps>(
function ConversationSidebar(
{ isOpen, onClose, currentConversationId, onSelectConversation, onNewConversation },
ref,
): React.ReactElement {
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const [hoveredId, setHoveredId] = useState<string | null>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
const loadSidebarData = useCallback(async (): Promise<void> => {
try {
setIsLoading(true);
setError(null);
const [loadedConversations, loadedProjects] = await Promise.all([
api<Conversation[]>('/api/conversations'),
api<Project[]>('/api/projects').catch(() => [] as Project[]),
]);
setConversations(
loadedConversations
.filter((conversation) => !conversation.archived)
.map(toSummary)
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)),
);
setProjects(loadedProjects);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load conversations');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void loadSidebarData();
}, [loadSidebarData]);
useEffect(() => {
if (!renamingId) return;
const timer = window.setTimeout(() => renameInputRef.current?.focus(), 0);
return () => window.clearTimeout(timer);
}, [renamingId]);
useImperativeHandle(
ref,
() => ({
refresh: () => {
void loadSidebarData();
},
addConversation: (conversation) => {
setConversations((prev) => {
const next = [conversation, ...prev.filter((item) => item.id !== conversation.id)];
return next.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
});
},
}),
[loadSidebarData],
);
const filteredConversations = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) return conversations;
return conversations.filter((conversation) =>
(conversation.title ?? 'Untitled conversation').toLowerCase().includes(query),
);
}, [conversations, searchQuery]);
const groupedConversations = useMemo<GroupedConversations[]>(() => {
if (projects.length === 0) {
return [
{
key: 'all',
label: 'All conversations',
projectId: null,
conversations: filteredConversations,
},
];
}
const byProject = new Map<string | null, ConversationSummary[]>();
for (const conversation of filteredConversations) {
const key = conversation.projectId ?? null;
const items = byProject.get(key) ?? [];
items.push(conversation);
byProject.set(key, items);
}
const groups: GroupedConversations[] = [];
for (const project of projects) {
const projectConversations = byProject.get(project.id);
if (!projectConversations?.length) continue;
groups.push({
key: project.id,
label: project.name,
projectId: project.id,
conversations: projectConversations,
});
}
const ungrouped = byProject.get(null);
if (ungrouped?.length) {
groups.push({
key: 'general',
label: 'General',
projectId: null,
conversations: ungrouped,
});
}
if (groups.length === 0) {
groups.push({
key: 'all',
label: 'All conversations',
projectId: null,
conversations: filteredConversations,
});
}
return groups;
}, [filteredConversations, projects]);
const startRename = useCallback((conversation: ConversationSummary): void => {
setPendingDeleteId(null);
setRenamingId(conversation.id);
setRenameValue(conversation.title ?? '');
}, []);
const cancelRename = useCallback((): void => {
setRenamingId(null);
setRenameValue('');
}, []);
const commitRename = useCallback(async (): Promise<void> => {
if (!renamingId) return;
const title = renameValue.trim() || 'Untitled conversation';
try {
const updated = await api<Conversation>(`/api/conversations/${renamingId}`, {
method: 'PATCH',
body: { title },
});
const summary = toSummary(updated);
setConversations((prev) =>
prev
.map((conversation) => (conversation.id === renamingId ? summary : conversation))
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)),
);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to rename conversation');
} finally {
setRenamingId(null);
setRenameValue('');
}
}, [renameValue, renamingId]);
const deleteConversation = useCallback(
async (conversationId: string): Promise<void> => {
try {
await api<void>(`/api/conversations/${conversationId}`, { method: 'DELETE' });
setConversations((prev) =>
prev.filter((conversation) => conversation.id !== conversationId),
);
if (currentConversationId === conversationId) {
onSelectConversation(null);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete conversation');
} finally {
setPendingDeleteId(null);
}
},
[currentConversationId, onSelectConversation],
);
return (
<>
{isOpen ? (
<button
type="button"
aria-label="Close conversation sidebar"
className="fixed inset-0 z-30 bg-black/50 md:hidden"
onClick={onClose}
/>
) : null}
<aside
aria-label="Conversation sidebar"
className="fixed left-0 top-0 z-40 flex h-full flex-col border-r md:relative md:z-0"
style={{
width: 'var(--sidebar-w)',
background: 'var(--bg)',
borderColor: 'var(--border)',
transform: isOpen ? 'translateX(0)' : 'translateX(calc(-1 * var(--sidebar-w)))',
transition: 'transform 220ms var(--ease)',
}}
>
<div
className="flex items-center justify-between border-b px-4 py-3"
style={{ borderColor: 'var(--border)' }}
>
<div>
<p className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
Conversations
</p>
<p className="text-xs" style={{ color: 'var(--muted)' }}>
Search, rename, and manage threads
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-md p-2 md:hidden"
style={{ color: 'var(--text-2)' }}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor">
<path strokeWidth="2" strokeLinecap="round" d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
</div>
<div className="space-y-3 border-b p-3" style={{ borderColor: 'var(--border)' }}>
<button
type="button"
onClick={() => onNewConversation(null)}
className="flex w-full items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors"
style={{
borderColor: 'var(--primary)',
background: 'color-mix(in srgb, var(--primary) 12%, transparent)',
color: 'var(--text)',
}}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor">
<path strokeWidth="2" strokeLinecap="round" d="M12 5v14M5 12h14" />
</svg>
New conversation
</button>
<div className="relative">
<svg
viewBox="0 0 24 24"
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
fill="none"
stroke="currentColor"
style={{ color: 'var(--muted)' }}
>
<circle cx="11" cy="11" r="7" strokeWidth="2" />
<path d="m20 20-3.5-3.5" strokeWidth="2" strokeLinecap="round" />
</svg>
<input
type="search"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search conversations"
className="w-full rounded-lg border px-9 py-2 text-sm outline-none"
style={{
background: 'var(--surface)',
borderColor: 'var(--border)',
color: 'var(--text)',
}}
/>
</div>
</div>
<div className="flex-1 overflow-y-auto p-3">
{isLoading ? (
<div className="py-8 text-center text-sm" style={{ color: 'var(--muted)' }}>
Loading conversations...
</div>
) : error ? (
<div
className="space-y-3 rounded-xl border p-4 text-sm"
style={{
background: 'color-mix(in srgb, var(--danger) 10%, var(--surface))',
borderColor: 'color-mix(in srgb, var(--danger) 35%, var(--border))',
color: 'var(--text)',
}}
>
<p>{error}</p>
<button
type="button"
onClick={() => void loadSidebarData()}
className="rounded-md px-3 py-1.5 text-xs font-medium"
style={{ background: 'var(--danger)', color: 'white' }}
>
Retry
</button>
</div>
) : filteredConversations.length === 0 ? (
<div className="py-10 text-center">
<p className="text-sm" style={{ color: 'var(--text-2)' }}>
{searchQuery ? 'No matching conversations' : 'No conversations yet'}
</p>
<p className="mt-1 text-xs" style={{ color: 'var(--muted)' }}>
{searchQuery ? 'Try another title search.' : 'Start a new conversation to begin.'}
</p>
</div>
) : (
<div className="space-y-4">
{groupedConversations.map((group) => (
<section key={group.key} className="space-y-2">
{projects.length > 0 ? (
<div className="flex items-center justify-between px-1">
<h3
className="text-[11px] font-semibold uppercase tracking-[0.16em]"
style={{ color: 'var(--muted)' }}
>
{group.label}
</h3>
<button
type="button"
onClick={() => onNewConversation(group.projectId)}
className="rounded-md px-2 py-1 text-[11px] font-medium"
style={{ color: 'var(--ms-blue-500)' }}
>
New
</button>
</div>
) : null}
<div className="space-y-1">
{group.conversations.map((conversation) => {
const isActive = currentConversationId === conversation.id;
const isRenaming = renamingId === conversation.id;
const showActions =
hoveredId === conversation.id ||
isRenaming ||
pendingDeleteId === conversation.id;
return (
<div
key={conversation.id}
onMouseEnter={() => setHoveredId(conversation.id)}
onMouseLeave={() =>
setHoveredId((current) =>
current === conversation.id ? null : current,
)
}
className="rounded-xl border p-2 transition-colors"
style={{
borderColor: isActive
? 'color-mix(in srgb, var(--primary) 60%, var(--border))'
: 'transparent',
background: isActive ? 'var(--surface-2)' : 'transparent',
}}
>
{isRenaming ? (
<input
ref={renameInputRef}
value={renameValue}
onChange={(event) => setRenameValue(event.target.value)}
onBlur={() => void commitRename()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void commitRename();
}
if (event.key === 'Escape') {
event.preventDefault();
cancelRename();
}
}}
maxLength={255}
className="w-full rounded-md border px-2 py-1.5 text-sm outline-none"
style={{
background: 'var(--surface)',
borderColor: 'var(--ms-blue-500)',
color: 'var(--text)',
}}
/>
) : (
<button
type="button"
onClick={() => onSelectConversation(conversation.id)}
className="block w-full text-left"
>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<p
className="truncate text-sm font-medium"
style={{
color: isActive ? 'var(--text)' : 'var(--text-2)',
}}
>
{conversation.title ?? 'Untitled conversation'}
</p>
<p className="mt-1 text-xs" style={{ color: 'var(--muted)' }}>
{formatRelativeTime(conversation.updatedAt)}
</p>
</div>
{showActions ? (
<div className="flex items-center gap-1">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
startRename(conversation);
}}
className="rounded-md p-1.5 transition-colors"
style={{ color: 'var(--text-2)' }}
aria-label={`Rename ${conversation.title ?? 'conversation'}`}
>
<svg
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
>
<path
d="M4 20h4l10.5-10.5a1.4 1.4 0 0 0 0-2L16.5 5.5a1.4 1.4 0 0 0-2 0L4 16v4Z"
strokeWidth="1.8"
strokeLinejoin="round"
/>
</svg>
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setPendingDeleteId((current) =>
current === conversation.id ? null : conversation.id,
);
setRenamingId(null);
}}
className="rounded-md p-1.5 transition-colors"
style={{ color: 'var(--danger)' }}
aria-label={`Delete ${conversation.title ?? 'conversation'}`}
>
<svg
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
>
<path
d="M4 7h16M10 11v6M14 11v6M6 7l1 12h10l1-12M9 7V4h6v3"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
) : null}
</div>
</button>
)}
{pendingDeleteId === conversation.id ? (
<div
className="mt-2 flex items-center justify-between rounded-lg border px-2 py-2"
style={{
borderColor:
'color-mix(in srgb, var(--danger) 45%, var(--border))',
background:
'color-mix(in srgb, var(--danger) 10%, var(--surface))',
}}
>
<p className="text-xs" style={{ color: 'var(--text-2)' }}>
Delete this conversation?
</p>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setPendingDeleteId(null)}
className="rounded-md px-2 py-1 text-xs"
style={{ color: 'var(--text-2)' }}
>
Cancel
</button>
<button
type="button"
onClick={() => void deleteConversation(conversation.id)}
className="rounded-md px-2 py-1 text-xs font-medium"
style={{ background: 'var(--danger)', color: 'white' }}
>
Delete
</button>
</div>
</div>
) : null}
</div>
);
})}
</div>
</section>
))}
</div>
)}
</div>
</aside>
</>
);
},
);

View File

@@ -1,7 +1,5 @@
'use client'; 'use client';
import { useCallback, useMemo, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
import type { Message } from '@/lib/types'; import type { Message } from '@/lib/types';
@@ -11,261 +9,27 @@ interface MessageBubbleProps {
export function MessageBubble({ message }: MessageBubbleProps): React.ReactElement { export function MessageBubble({ message }: MessageBubbleProps): React.ReactElement {
const isUser = message.role === 'user'; const isUser = message.role === 'user';
const isSystem = message.role === 'system';
const [copied, setCopied] = useState(false);
const [thinkingExpanded, setThinkingExpanded] = useState(false);
const { response, thinking } = useMemo(
() => parseThinking(message.content, message.thinking),
[message.content, message.thinking],
);
const handleCopy = useCallback(async (): Promise<void> => {
try {
await navigator.clipboard.writeText(response);
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
} catch (error) {
console.error('[MessageBubble] Failed to copy message:', error);
}
}, [response]);
if (isSystem) {
return (
<div className="flex justify-center">
<div
className="max-w-[42rem] rounded-full border px-3 py-1.5 text-xs backdrop-blur-sm"
style={{
borderColor: 'var(--color-border)',
backgroundColor: 'color-mix(in srgb, var(--color-surface) 70%, transparent)',
color: 'var(--color-muted)',
}}
>
<span>{response}</span>
</div>
</div>
);
}
return ( return (
<div className={cn('group flex', isUser ? 'justify-end' : 'justify-start')}> <div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
<div <div
className={cn( className={cn(
'flex max-w-[min(78ch,85%)] flex-col gap-2', 'max-w-[75%] rounded-xl px-4 py-3 text-sm',
isUser ? 'items-end' : 'items-start', isUser
? 'bg-blue-600 text-white'
: 'border border-surface-border bg-surface-elevated text-text-primary',
)} )}
> >
<div className={cn('flex items-center gap-2 text-[11px]', isUser && 'flex-row-reverse')}> <div className="whitespace-pre-wrap break-words">{message.content}</div>
<span className="font-medium text-[var(--color-text-2)]">
{isUser ? 'You' : 'Assistant'}
</span>
{!isUser && message.model ? (
<span
className="rounded-full border px-2 py-0.5 font-medium text-[var(--color-text-2)]"
style={{
backgroundColor: 'color-mix(in srgb, var(--color-surface-2) 82%, transparent)',
borderColor: 'var(--color-border)',
}}
title={message.provider ? `Provider: ${message.provider}` : undefined}
>
{message.model}
</span>
) : null}
{!isUser && typeof message.totalTokens === 'number' && message.totalTokens > 0 ? (
<span
className="rounded-full border px-2 py-0.5 text-[var(--color-muted)]"
style={{ borderColor: 'var(--color-border)' }}
>
{formatTokenCount(message.totalTokens)}
</span>
) : null}
<span className="text-[var(--color-muted)]">{formatTimestamp(message.createdAt)}</span>
</div>
{thinking && !isUser ? (
<div
className="w-full overflow-hidden rounded-2xl border"
style={{
backgroundColor: 'color-mix(in srgb, var(--color-surface-2) 88%, transparent)',
borderColor: 'var(--color-border)',
}}
>
<button
type="button"
onClick={() => setThinkingExpanded((prev) => !prev)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-[var(--color-text-2)] transition-colors hover:bg-black/5"
aria-expanded={thinkingExpanded}
>
<span
className={cn(
'inline-block text-[10px] transition-transform',
thinkingExpanded && 'rotate-90',
)}
>
</span>
<span>Chain of thought</span>
<span className="ml-auto text-[var(--color-muted)]">
{thinkingExpanded ? 'Hide' : 'Show'}
</span>
</button>
{thinkingExpanded ? (
<pre
className="overflow-x-auto border-t px-3 py-3 font-mono text-xs leading-6 whitespace-pre-wrap"
style={{
borderColor: 'var(--color-border)',
backgroundColor: 'var(--color-bg-deep)',
color: 'var(--color-text-2)',
}}
>
{thinking}
</pre>
) : null}
</div>
) : null}
<div <div
className={cn( className={cn('mt-1 text-right text-xs', isUser ? 'text-blue-200' : 'text-text-muted')}
'relative w-full rounded-3xl px-4 py-3 text-sm shadow-[var(--shadow-ms-sm)]',
!isUser && 'border',
)}
style={{
backgroundColor: isUser ? 'var(--color-ms-blue-500)' : 'var(--color-surface)',
color: isUser ? '#fff' : 'var(--color-text)',
borderColor: isUser ? 'transparent' : 'var(--color-border)',
}}
> >
<div className="max-w-none"> {new Date(message.createdAt).toLocaleTimeString([], {
<ReactMarkdown hour: '2-digit',
components={{ minute: '2-digit',
p: ({ children }) => <p className="mb-3 leading-7 last:mb-0">{children}</p>, })}
ul: ({ children }) => <ul className="mb-3 list-disc pl-5 last:mb-0">{children}</ul>,
ol: ({ children }) => (
<ol className="mb-3 list-decimal pl-5 last:mb-0">{children}</ol>
),
li: ({ children }) => <li className="mb-1">{children}</li>,
a: ({ href, children }) => (
<a
href={href}
className="font-medium underline underline-offset-4"
target="_blank"
rel="noreferrer"
>
{children}
</a>
),
pre: ({ children }) => <div className="mb-3 last:mb-0">{children}</div>,
code: ({ className, children, ...props }) => {
const language = className?.replace('language-', '');
const content = String(children).replace(/\n$/, '');
const isInline = !className;
if (isInline) {
return (
<code
className="rounded-md px-1.5 py-0.5 font-mono text-[0.9em]"
style={{
backgroundColor:
'color-mix(in srgb, var(--color-bg-deep) 76%, transparent)',
}}
{...props}
>
{children}
</code>
);
}
return (
<div
className="overflow-hidden rounded-2xl border"
style={{
backgroundColor: 'var(--color-bg-deep)',
borderColor: 'var(--color-border)',
}}
>
<div
className="border-b px-3 py-2 font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--color-muted)]"
style={{ borderColor: 'var(--color-border)' }}
>
{language || 'code'}
</div>
<pre className="overflow-x-auto p-3">
<code
className={cn('font-mono text-[13px] leading-6', className)}
{...props}
>
{content}
</code>
</pre>
</div>
);
},
blockquote: ({ children }) => (
<blockquote
className="mb-3 border-l-2 pl-4 italic last:mb-0"
style={{ borderColor: 'var(--color-ms-blue-500)' }}
>
{children}
</blockquote>
),
}}
>
{response}
</ReactMarkdown>
</div>
<button
type="button"
onClick={() => void handleCopy()}
className="absolute -right-2 -top-2 rounded-full border p-2 opacity-0 shadow-[var(--shadow-ms-md)] transition-all group-hover:opacity-100 focus:opacity-100"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',
color: copied ? 'var(--color-success)' : 'var(--color-text-2)',
}}
aria-label={copied ? 'Copied' : 'Copy message'}
title={copied ? 'Copied' : 'Copy message'}
>
{copied ? '✓' : '⧉'}
</button>
</div> </div>
</div> </div>
</div> </div>
); );
} }
function parseThinking(
content: string,
thinking?: string,
): { response: string; thinking: string | null } {
if (thinking) {
return { response: content, thinking };
}
const regex = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi;
const matches = [...content.matchAll(regex)];
if (matches.length === 0) {
return { response: content, thinking: null };
}
return {
response: content.replace(regex, '').trim(),
thinking:
matches
.map((match) => match[1]?.trim() ?? '')
.filter(Boolean)
.join('\n\n') || null,
};
}
function formatTimestamp(createdAt: string): string {
return new Date(createdAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
}
function formatTokenCount(totalTokens: number): string {
if (totalTokens >= 1_000_000) return `${(totalTokens / 1_000_000).toFixed(1)}M tokens`;
if (totalTokens >= 1_000) return `${(totalTokens / 1_000).toFixed(1)}k tokens`;
return `${totalTokens} tokens`;
}

View File

@@ -1,97 +1,26 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; /** Renders an in-progress assistant message from streaming text. */
interface StreamingMessageProps { interface StreamingMessageProps {
text: string; text: string;
modelName?: string | null;
thinking?: string;
} }
const WAITING_QUIPS = [ export function StreamingMessage({ text }: StreamingMessageProps): React.ReactElement {
'The AI is warming up... give it a moment.',
'Brewing some thoughts...',
'Summoning intelligence from the void...',
'Consulting the silicon oracle...',
'Teaching electrons to think...',
];
const TIMEOUT_QUIPS = [
'The model wandered off. Lets try to find it again.',
'Response is taking the scenic route.',
'That answer is clearly overthinking things.',
'Still working. Either brilliance or a detour.',
];
export function StreamingMessage({
text,
modelName,
thinking,
}: StreamingMessageProps): React.ReactElement {
const [elapsedMs, setElapsedMs] = useState(0);
useEffect(() => {
setElapsedMs(0);
const startedAt = Date.now();
const timer = window.setInterval(() => {
setElapsedMs(Date.now() - startedAt);
}, 1000);
return () => window.clearInterval(timer);
}, [text, modelName, thinking]);
const quip = useMemo(() => {
if (elapsedMs >= 18_000) {
return TIMEOUT_QUIPS[Math.floor((elapsedMs / 1000) % TIMEOUT_QUIPS.length)];
}
if (elapsedMs >= 4_000) {
return WAITING_QUIPS[Math.floor((elapsedMs / 1000) % WAITING_QUIPS.length)];
}
return null;
}, [elapsedMs]);
return ( return (
<div className="flex justify-start"> <div className="flex justify-start">
<div <div className="max-w-[75%] rounded-xl border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-primary">
className="max-w-[min(78ch,85%)] rounded-3xl border px-4 py-3 text-sm shadow-[var(--shadow-ms-sm)]"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',
color: 'var(--color-text)',
}}
>
<div className="mb-2 flex items-center gap-2 text-[11px]">
<span className="font-medium text-[var(--color-text-2)]">Assistant</span>
{modelName ? (
<span className="rounded-full border border-[var(--color-border)] px-2 py-0.5 text-[var(--color-text-2)]">
{modelName}
</span>
) : null}
<span className="text-[var(--color-muted)]">{text ? 'Responding…' : 'Thinking…'}</span>
</div>
{text ? ( {text ? (
<div className="whitespace-pre-wrap break-words">{text}</div> <div className="whitespace-pre-wrap break-words">{text}</div>
) : ( ) : (
<div className="flex items-center gap-2 text-[var(--color-muted)]"> <div className="flex items-center gap-2 text-text-muted">
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-[var(--color-ms-blue-500)]" /> <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500" />
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-[var(--color-ms-blue-500)] [animation-delay:0.2s]" /> <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500 [animation-delay:0.2s]" />
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-[var(--color-ms-blue-500)] [animation-delay:0.4s]" /> <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500 [animation-delay:0.4s]" />
</div> </div>
)} )}
{thinking ? ( <div className="mt-1 flex items-center gap-1 text-xs text-text-muted">
<div <span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />
className="mt-3 rounded-2xl border px-3 py-2 font-mono text-xs whitespace-pre-wrap" {text ? 'Responding...' : 'Thinking...'}
style={{
backgroundColor: 'var(--color-bg-deep)',
borderColor: 'var(--color-border)',
color: 'var(--color-text-2)',
}}
>
{thinking}
</div>
) : null}
<div className="mt-2 flex items-center gap-2 text-xs text-[var(--color-muted)]">
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-[var(--color-ms-blue-500)]" />
<span>{quip ?? (text ? 'Responding…' : 'Thinking…')}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,239 +0,0 @@
'use client';
import Link from 'next/link';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { signOut, useSession } from '@/lib/auth-client';
interface AppHeaderProps {
conversationTitle?: string | null;
isSidebarOpen: boolean;
onToggleSidebar: () => void;
}
type ThemeMode = 'dark' | 'light';
const THEME_STORAGE_KEY = 'mosaic-chat-theme';
export function AppHeader({
conversationTitle,
isSidebarOpen,
onToggleSidebar,
}: AppHeaderProps): React.ReactElement {
const { data: session } = useSession();
const [currentTime, setCurrentTime] = useState('');
const [version, setVersion] = useState<string | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [theme, setTheme] = useState<ThemeMode>('dark');
useEffect(() => {
function updateTime(): void {
setCurrentTime(
new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
}),
);
}
updateTime();
const interval = window.setInterval(updateTime, 60_000);
return () => window.clearInterval(interval);
}, []);
useEffect(() => {
fetch('/version.json')
.then(async (res) => res.json() as Promise<{ version?: string; commit?: string }>)
.then((data) => {
if (data.version) {
setVersion(data.commit ? `${data.version}+${data.commit}` : data.version);
}
})
.catch(() => setVersion(null));
}, []);
useEffect(() => {
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
const nextTheme = storedTheme === 'light' ? 'light' : 'dark';
applyTheme(nextTheme);
setTheme(nextTheme);
}, []);
const handleThemeToggle = useCallback(() => {
const nextTheme = theme === 'dark' ? 'light' : 'dark';
applyTheme(nextTheme);
window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
setTheme(nextTheme);
}, [theme]);
const handleSignOut = useCallback(async (): Promise<void> => {
await signOut();
window.location.href = '/login';
}, []);
const userLabel = session?.user.name ?? session?.user.email ?? 'Mosaic User';
const initials = useMemo(() => getInitials(userLabel), [userLabel]);
return (
<header
className="sticky top-0 z-20 border-b backdrop-blur-xl"
style={{
backgroundColor: 'color-mix(in srgb, var(--color-surface) 82%, transparent)',
borderColor: 'var(--color-border)',
}}
>
<div className="flex items-center justify-between gap-3 px-4 py-3 md:px-6">
<div className="flex min-w-0 items-center gap-3">
<button
type="button"
onClick={onToggleSidebar}
className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text)' }}
aria-label="Toggle conversation sidebar"
aria-expanded={isSidebarOpen}
>
</button>
<Link href="/chat" className="flex min-w-0 items-center gap-3">
<div
className="flex h-10 w-10 items-center justify-center rounded-2xl text-sm font-semibold text-white shadow-[var(--shadow-ms-md)]"
style={{
background:
'linear-gradient(135deg, var(--color-ms-blue-500), var(--color-ms-teal-500))',
}}
>
M
</div>
<div className="flex min-w-0 items-center gap-3">
<div className="text-sm font-semibold text-[var(--color-text)]">Mosaic</div>
<div className="hidden h-5 w-px bg-[var(--color-border)] md:block" />
<div className="hidden items-center gap-2 md:flex">
<span className="relative flex h-2.5 w-2.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--color-ms-teal-500)] opacity-60" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-[var(--color-ms-teal-500)]" />
</span>
<span className="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)]">
Online
</span>
</div>
</div>
</Link>
</div>
<div className="hidden min-w-0 items-center gap-3 md:flex">
<div className="rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-2)]">
{currentTime || '--:--'}
</div>
<div className="max-w-[24rem] truncate text-sm font-medium text-[var(--color-text)]">
{conversationTitle?.trim() || 'New Session'}
</div>
{version ? (
<div className="rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-muted)]">
v{version}
</div>
) : null}
</div>
<div className="flex items-center gap-2">
<div className="hidden items-center gap-2 lg:flex">
<ShortcutHint label="⌘/" text="focus" />
<ShortcutHint label="⌘K" text="focus" />
</div>
<button
type="button"
onClick={handleThemeToggle}
className="inline-flex h-10 items-center justify-center rounded-2xl border px-3 text-sm transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text)' }}
aria-label="Toggle theme"
>
{theme === 'dark' ? '☀︎' : '☾'}
</button>
<div className="relative">
<button
type="button"
onClick={() => setMenuOpen((prev) => !prev)}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border text-sm font-semibold transition-colors hover:bg-white/5"
style={{
backgroundColor: 'var(--color-surface-2)',
borderColor: 'var(--color-border)',
color: 'var(--color-text)',
}}
aria-expanded={menuOpen}
aria-label="Open user menu"
>
{session?.user.image ? (
<img
src={session.user.image}
alt={userLabel}
className="h-full w-full rounded-full object-cover"
/>
) : (
initials
)}
</button>
{menuOpen ? (
<div
className="absolute right-0 top-12 min-w-56 rounded-3xl border p-2 shadow-[var(--shadow-ms-lg)]"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',
}}
>
<div className="border-b px-3 py-2" style={{ borderColor: 'var(--color-border)' }}>
<div className="text-sm font-medium text-[var(--color-text)]">{userLabel}</div>
{session?.user.email ? (
<div className="text-xs text-[var(--color-muted)]">{session.user.email}</div>
) : null}
</div>
<div className="p-1">
<Link
href="/settings"
className="flex rounded-2xl px-3 py-2 text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
onClick={() => setMenuOpen(false)}
>
Settings
</Link>
<button
type="button"
onClick={() => void handleSignOut()}
className="flex w-full rounded-2xl px-3 py-2 text-left text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
>
Sign out
</button>
</div>
</div>
) : null}
</div>
</div>
</div>
</header>
);
}
function ShortcutHint({ label, text }: { label: string; text: string }): React.ReactElement {
return (
<span className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-muted)]">
<span className="font-medium text-[var(--color-text-2)]">{label}</span>
<span>{text}</span>
</span>
);
}
function getInitials(label: string): string {
const words = label.split(/\s+/).filter(Boolean).slice(0, 2);
if (words.length === 0) return 'M';
return words.map((word) => word.charAt(0).toUpperCase()).join('');
}
function applyTheme(theme: ThemeMode): void {
const root = document.documentElement;
if (theme === 'light') {
root.setAttribute('data-theme', 'light');
root.classList.remove('dark');
} else {
root.removeAttribute('data-theme');
root.classList.add('dark');
}
}

View File

@@ -1,16 +1,11 @@
'use client'; 'use client';
import { usePathname, useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useSession, signOut } from '@/lib/auth-client'; import { useSession, signOut } from '@/lib/auth-client';
export function Topbar(): React.ReactElement { export function Topbar(): React.ReactElement {
const { data: session } = useSession(); const { data: session } = useSession();
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
if (pathname.startsWith('/chat')) {
return <></>;
}
async function handleSignOut(): Promise<void> { async function handleSignOut(): Promise<void> {
await signOut(); await signOut();

View File

@@ -1,9 +1,9 @@
import { createAuthClient } from 'better-auth/react'; import { createAuthClient } from 'better-auth/react';
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins'; import { adminClient } from 'better-auth/client/plugins';
export const authClient = createAuthClient({ export const authClient = createAuthClient({
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000', baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000',
plugins: [adminClient(), genericOAuthClient()], plugins: [adminClient()],
}); });
export const { useSession, signIn, signUp, signOut } = authClient; export const { useSession, signIn, signUp, signOut } = authClient;

View File

@@ -1,48 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getEnabledSsoProviders, getSsoProvider } from './sso-providers';
describe('sso-providers', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('returns the enabled providers in login button order', () => {
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'true');
expect(getEnabledSsoProviders()).toEqual([
{
id: 'workos',
buttonLabel: 'Continue with WorkOS',
description: 'Enterprise SSO via WorkOS',
enabled: true,
href: '/auth/provider/workos',
},
{
id: 'keycloak',
buttonLabel: 'Continue with Keycloak',
description: 'Enterprise SSO via Keycloak',
enabled: true,
href: '/auth/provider/keycloak',
},
]);
});
it('marks disabled providers without exposing them in the enabled list', () => {
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'false');
expect(getEnabledSsoProviders().map((provider) => provider.id)).toEqual(['workos']);
expect(getSsoProvider('keycloak')).toEqual({
id: 'keycloak',
buttonLabel: 'Continue with Keycloak',
description: 'Enterprise SSO via Keycloak',
enabled: false,
href: '/auth/provider/keycloak',
});
});
it('returns null for unknown providers', () => {
expect(getSsoProvider('authentik')).toBeNull();
});
});

View File

@@ -1,53 +0,0 @@
export type SsoProviderId = 'workos' | 'keycloak';
export interface SsoProvider {
id: SsoProviderId;
buttonLabel: string;
description: string;
enabled: boolean;
href: string;
}
const PROVIDER_METADATA: Record<SsoProviderId, Omit<SsoProvider, 'enabled' | 'href'>> = {
workos: {
id: 'workos',
buttonLabel: 'Continue with WorkOS',
description: 'Enterprise SSO via WorkOS',
},
keycloak: {
id: 'keycloak',
buttonLabel: 'Continue with Keycloak',
description: 'Enterprise SSO via Keycloak',
},
};
export function getEnabledSsoProviders(): SsoProvider[] {
return (Object.keys(PROVIDER_METADATA) as SsoProviderId[])
.map((providerId) => getSsoProvider(providerId))
.filter((provider): provider is SsoProvider => provider?.enabled === true);
}
export function getSsoProvider(providerId: string): SsoProvider | null {
if (!isSsoProviderId(providerId)) {
return null;
}
return {
...PROVIDER_METADATA[providerId],
enabled: isSsoProviderEnabled(providerId),
href: `/auth/provider/${providerId}`,
};
}
function isSsoProviderId(value: string): value is SsoProviderId {
return value === 'workos' || value === 'keycloak';
}
function isSsoProviderEnabled(providerId: SsoProviderId): boolean {
switch (providerId) {
case 'workos':
return process.env['NEXT_PUBLIC_WORKOS_ENABLED'] === 'true';
case 'keycloak':
return process.env['NEXT_PUBLIC_KEYCLOAK_ENABLED'] === 'true';
}
}

View File

@@ -15,41 +15,10 @@ export interface Message {
conversationId: string; conversationId: string;
role: 'user' | 'assistant' | 'system'; role: 'user' | 'assistant' | 'system';
content: string; content: string;
thinking?: string;
model?: string;
provider?: string;
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
createdAt: string; createdAt: string;
} }
/** Model definition returned by provider APIs. */
export interface ModelInfo {
id: string;
provider: string;
name: string;
reasoning: boolean;
contextWindow: number;
maxTokens: number;
inputTypes: Array<'text' | 'image'>;
cost: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
};
}
/** Provider with associated models. */
export interface ProviderInfo {
id: string;
name: string;
available: boolean;
models: ModelInfo[];
}
/** Task statuses. */ /** Task statuses. */
export type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled'; export type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';

View File

@@ -1,164 +0,0 @@
# Performance Optimization — P8-003
**Branch:** `feat/p8-003-performance`
**Target metrics:** <200 ms TTFB, <2 s page loads
---
## What Was Profiled
The following areas were reviewed through static analysis and code-path tracing
(no production traffic available; findings are based on measurable code-level patterns):
| Area | Findings |
| ---------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `packages/db` | Connection pool unbounded (default 10, no idle/connect timeout) |
| `apps/gateway/src/preferences` | N+1 round-trip on every pref upsert (SELECT + INSERT/UPDATE) |
| `packages/brain/src/conversations` | Unbounded list queries — no `LIMIT` or `ORDER BY` |
| `packages/db/src/schema` | Missing hot-path indexes: auth session lookup, OAuth callback, conversation list, agent-log tier queries |
| `apps/gateway/src/gc` | Cold-start GC blocked NestJS bootstrap (synchronous `await` in `onModuleInit`) |
| `apps/web/next.config.ts` | Missing `compress: true`, no `productionBrowserSourceMaps: false`, no image format config |
---
## Changes Made
### 1. DB Connection Pool — `packages/db/src/client.ts`
**Problem:** `postgres()` was called with no pool config. The default max of 10 connections
and no idle/connect timeouts meant the pool could hang indefinitely on a stale TCP connection.
**Fix:**
- `max`: 20 connections (configurable via `DB_POOL_MAX`)
- `idle_timeout`: 30 s (configurable via `DB_IDLE_TIMEOUT`) — recycle stale connections
- `connect_timeout`: 5 s (configurable via `DB_CONNECT_TIMEOUT`) — fail fast on unreachable DB
**Expected impact:** Eliminates pool exhaustion under moderate concurrency; removes indefinite
hangs when the DB is temporarily unreachable.
---
### 2. Preferences Upsert — `apps/gateway/src/preferences/preferences.service.ts`
**Problem:** `upsertPref` executed two serial DB round-trips on every preference write:
```
1. SELECT id FROM preferences WHERE user_id = ? AND key = ? (→ check exists)
2a. UPDATE preferences SET value = ? … (→ if found)
2b. INSERT INTO preferences … (→ if not found)
```
Under concurrency this also had a TOCTOU race window.
**Fix:** Replaced with single-statement `INSERT … ON CONFLICT DO UPDATE`:
```sql
INSERT INTO preferences (user_id, key, value, mutable)
VALUES (?, ?, ?, true)
ON CONFLICT (user_id, key) DO UPDATE SET value = excluded.value, updated_at = now();
```
This required promoting `preferences_user_key_idx` from a plain index to a `UNIQUE INDEX`
(see migration `0003_p8003_perf_indexes.sql`).
**Expected impact:** ~50% reduction in DB round-trips for preference writes; eliminates
the race window.
---
### 3. Missing DB Indexes — `packages/db/src/schema.ts` + migration
The following indexes were added or replaced to cover common query patterns:
| Table | Old indexes | New / changed |
| --------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `sessions` | _(none)_ | `sessions_user_id_idx(user_id)`, `sessions_expires_at_idx(expires_at)` |
| `accounts` | _(none)_ | `accounts_provider_account_idx(provider_id, account_id)`, `accounts_user_id_idx(user_id)` |
| `conversations` | `(user_id)`, `(archived)` separate | `conversations_user_archived_idx(user_id, archived)` compound |
| `agent_logs` | `(session_id)`, `(tier)`, `(created_at)` separate | `agent_logs_session_tier_idx(session_id, tier)`, `agent_logs_tier_created_at_idx(tier, created_at)` |
| `preferences` | non-unique `(user_id, key)` | **unique** `(user_id, key)` — required for `ON CONFLICT` |
**Expected impact:**
- Auth session validation (hot path on every request): from seq scan → index scan
- OAuth callback account lookup: from seq scan → index scan
- Conversation list (dashboard load): compound index covers `WHERE user_id = ? ORDER BY updated_at`
- Log summarisation cron: `(tier, created_at)` index enables efficient hot→warm promotion query
All changes are in `packages/db/drizzle/0003_p8003_perf_indexes.sql`.
---
### 4. Conversation Queries — `packages/brain/src/conversations.ts`
**Problem:** `findAll(userId)` and `findMessages(conversationId)` were unbounded — no `LIMIT`
and `findAll` had no `ORDER BY`, so the DB planner may not use the index efficiently.
**Fix:**
- `findAll`: `ORDER BY updated_at DESC LIMIT 200` — returns most-recent conversations first
- `findMessages`: `ORDER BY created_at ASC LIMIT 500` — chronological message history
**Expected impact:** Prevents accidental full-table scans on large datasets; ensures the
frontend receives a usable, ordered result set regardless of table growth.
---
### 5. Cold-Start GC — `apps/gateway/src/gc/session-gc.service.ts`
**Problem:** `onModuleInit()` was `async` and `await`-ed `fullCollect()`, which blocked the
NestJS module initialization chain. Full GC — which calls `redis.keys('mosaic:session:*')` and
a DB query — typically takes 100500 ms. This directly added to startup TTFB.
**Fix:** Made `onModuleInit()` synchronous and used `.then().catch()` to run GC in the
background. The first HTTP request is no longer delayed by GC work.
**Expected impact:** Removes 100500 ms from cold-start TTFB.
---
### 6. Next.js Config — `apps/web/next.config.ts`
**Problem:** `compress: true` was not set, so response payloads were uncompressed. No image
format optimization or source-map suppression was configured.
**Fix:**
- `compress: true` — enables gzip/brotli for all Next.js responses
- `productionBrowserSourceMaps: false` — reduces build output size
- `images.formats: ['image/avif', 'image/webp']` — Next.js Image component will serve modern
formats to browsers that support them (typically 4060% smaller than JPEG/PNG)
**Expected impact:** Typical HTML/JSON gzip savings of 6080%; image serving cost reduced
for any `<Image>` components added in the future.
---
## What Was Not Changed (Intentionally)
- **Caching layer (Valkey/Redis):** The `SystemOverrideService` and GC already use Redis
pipelines. `PreferencesService.getEffective()` reads all user prefs in one query — this
is appropriate for the data size and doesn't warrant an additional cache layer yet.
- **WebSocket backpressure:** The `ChatGateway` already drops events for disconnected clients
(`client.connected` check) and cleans up listeners on disconnect. No memory leak was found.
- **Plugin/skill loader startup:** `SkillLoaderService.loadForSession()` is called on first
session creation, not on startup. Already non-blocking.
- **Frontend React memoization:** No specific hot components were identified as causing
excessive re-renders without profiling data. No speculative `memo()` calls added.
---
## How to Apply
```bash
# Run the DB migration (requires a live DB)
pnpm --filter @mosaic/db exec drizzle-kit migrate
# Or, in Docker/Swarm — migrations run automatically on gateway startup
# via runMigrations() in packages/db/src/migrate.ts
```
---
_Generated by P8-003 performance optimization task — 2026-03-18_

View File

@@ -1,111 +0,0 @@
# SSO Providers
Mosaic Stack supports optional enterprise single sign-on through Better Auth's generic OAuth flow. The gateway mounts Better Auth under `/api/auth`, so every provider callback terminates at:
```text
{BETTER_AUTH_URL}/api/auth/oauth2/callback/{providerId}
```
For the providers in this document:
- Authentik: `{BETTER_AUTH_URL}/api/auth/oauth2/callback/authentik`
- WorkOS: `{BETTER_AUTH_URL}/api/auth/oauth2/callback/workos`
- Keycloak: `{BETTER_AUTH_URL}/api/auth/oauth2/callback/keycloak`
## Required environment variables
### Authentik
```bash
AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic
AUTHENTIK_CLIENT_ID=...
AUTHENTIK_CLIENT_SECRET=...
```
### WorkOS
```bash
WORKOS_ISSUER=https://your-company.authkit.app
WORKOS_CLIENT_ID=client_...
WORKOS_CLIENT_SECRET=...
NEXT_PUBLIC_WORKOS_ENABLED=true
```
`WORKOS_ISSUER` should be the WorkOS AuthKit issuer or custom auth domain, not the raw REST API hostname. Mosaic derives the OIDC discovery URL from that issuer.
### Keycloak
```bash
KEYCLOAK_ISSUER=https://auth.example.com/realms/master
KEYCLOAK_CLIENT_ID=mosaic
KEYCLOAK_CLIENT_SECRET=...
NEXT_PUBLIC_KEYCLOAK_ENABLED=true
```
If you prefer, you can keep the issuer split as:
```bash
KEYCLOAK_URL=https://auth.example.com
KEYCLOAK_REALM=master
```
The auth package will derive `KEYCLOAK_ISSUER` from those two values.
## WorkOS setup
1. In WorkOS, create or select the application that will back Mosaic login.
2. Configure an AuthKit domain or custom authentication domain for the application.
3. Add the redirect URI:
```text
{BETTER_AUTH_URL}/api/auth/oauth2/callback/workos
```
4. Copy the application's `client_id` and `client_secret` into `WORKOS_CLIENT_ID` and `WORKOS_CLIENT_SECRET`.
5. Set `WORKOS_ISSUER` to the AuthKit domain from step 2.
6. Create the WorkOS organization and attach the enterprise SSO connection you want Mosaic to use.
7. Set `NEXT_PUBLIC_WORKOS_ENABLED=true` in the web deployment so the login button is rendered.
## Keycloak setup
1. Start from an existing Keycloak realm or create a dedicated realm for Mosaic.
2. Create a confidential OIDC client named `mosaic` or your preferred client ID.
3. Set the valid redirect URI to:
```text
{BETTER_AUTH_URL}/api/auth/oauth2/callback/keycloak
```
4. Set the web origin to the public Mosaic web URL.
5. Copy the client secret into `KEYCLOAK_CLIENT_SECRET`.
6. Set either `KEYCLOAK_ISSUER` directly or `KEYCLOAK_URL` + `KEYCLOAK_REALM`.
7. Set `NEXT_PUBLIC_KEYCLOAK_ENABLED=true` in the web deployment so the login button is rendered.
### Local Keycloak smoke test
If you want to test locally with Docker:
```bash
docker run --rm --name mosaic-keycloak \
-p 8080:8080 \
-e KEYCLOAK_ADMIN=admin \
-e KEYCLOAK_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.1 start-dev
```
Then configure:
```bash
KEYCLOAK_ISSUER=http://localhost:8080/realms/master
KEYCLOAK_CLIENT_ID=mosaic
KEYCLOAK_CLIENT_SECRET=...
NEXT_PUBLIC_KEYCLOAK_ENABLED=true
```
## Web flow
The web login page renders provider buttons from `NEXT_PUBLIC_*_ENABLED` flags. Each button links to `/auth/provider/{providerId}`, and that page initiates Better Auth's `signIn.oauth2` flow before handing off to the provider.
## Failure mode
Provider config is optional, but partial config is rejected at startup. If any provider-specific env var is present without the full required set, `@mosaic/auth` throws a bootstrap error with the missing keys instead of silently registering a broken provider.

View File

@@ -93,8 +93,8 @@
| P8-017 | done | Phase 8 | TUI Phase 8 — autocomplete sidebar, fuzzy match, arg hints, up-arrow history | #184 | #170 | | P8-017 | done | Phase 8 | TUI Phase 8 — autocomplete sidebar, fuzzy match, arg hints, up-arrow history | #184 | #170 |
| P8-018 | done | Phase 8 | Spin-off plan stubs — Gatekeeper, Task Queue Unification, Chroot Sandboxing | — | #171 | | P8-018 | done | Phase 8 | Spin-off plan stubs — Gatekeeper, Task Queue Unification, Chroot Sandboxing | — | #171 |
| P8-019 | done | Phase 8 | Verify Platform Architecture — integration + E2E verification | #185 | #172 | | P8-019 | done | Phase 8 | Verify Platform Architecture — integration + E2E verification | #185 | #172 |
| P8-001 | in-progress | codex | Phase 8 | Additional SSO providers — WorkOS + Keycloak | #210 | #53 | | P8-001 | not-started | codex | Phase 8 | Additional SSO providers — WorkOS + Keycloak | | #53 |
| P8-002 | in-progress | codex | Phase 8 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 | | P8-002 | not-started | codex | Phase 8 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 |
| P8-003 | in-progress | codex | Phase 8 | Performance optimization | — | #56 | | P8-003 | not-started | codex | Phase 8 | Performance optimization | — | #56 |
| P8-004 | done | haiku | Phase 8 | Beta release gate — v0.1.0 tag | — | #59 | | P8-004 | not-started | haiku | Phase 8 | Beta release gate — v0.1.0 tag | — | #59 |
| FIX-01 | done | Backlog | Call piSession.dispose() in AgentService.destroySession | #78 | #62 | | FIX-01 | done | Backlog | Call piSession.dispose() in AgentService.destroySession | #78 | #62 |

View File

@@ -1,11 +1,9 @@
# BUG-CLI Scratchpad # BUG-CLI Scratchpad
## Objective ## Objective
Fix 4 CLI/TUI polish bugs in a single PR (issues #192, #193, #194, #199). Fix 4 CLI/TUI polish bugs in a single PR (issues #192, #193, #194, #199).
## Issues ## Issues
- #192: Ctrl+T leaks 't' into input - #192: Ctrl+T leaks 't' into input
- #193: Duplicate React keys in CommandAutocomplete - #193: Duplicate React keys in CommandAutocomplete
- #194: /provider login false clipboard claim - #194: /provider login false clipboard claim
@@ -14,33 +12,28 @@ Fix 4 CLI/TUI polish bugs in a single PR (issues #192, #193, #194, #199).
## Plan and Fixes ## Plan and Fixes
### Bug #192 — Ctrl+T character leak ### Bug #192 — Ctrl+T character leak
- Location: `packages/cli/src/tui/app.tsx` - Location: `packages/cli/src/tui/app.tsx`
- Fix: Added `ctrlJustFired` ref. Set synchronously in Ctrl+T/L/N/K handlers, cleared via microtask. - Fix: Added `ctrlJustFired` ref. Set synchronously in Ctrl+T/L/N/K handlers, cleared via microtask.
In the `onChange` wrapper passed to `InputBar`, if `ctrlJustFired.current` is true, suppress the In the `onChange` wrapper passed to `InputBar`, if `ctrlJustFired.current` is true, suppress the
leaked character and return early. leaked character and return early.
### Bug #193 — Duplicate React keys ### Bug #193 — Duplicate React keys
- Location: `packages/cli/src/tui/components/command-autocomplete.tsx` - Location: `packages/cli/src/tui/components/command-autocomplete.tsx`
- Fix: Changed `key={cmd.name}` to `key={`${cmd.execution}-${cmd.name}`}` for uniqueness. - Fix: Changed `key={cmd.name}` to `key={`${cmd.execution}-${cmd.name}`}` for uniqueness.
- Also: `packages/cli/src/tui/commands/registry.ts``getAll()` now deduplicates gateway commands - Also: `packages/cli/src/tui/commands/registry.ts``getAll()` now deduplicates gateway commands
that share a name with local commands. Local commands take precedence. that share a name with local commands. Local commands take precedence.
### Bug #194 — False clipboard claim ### Bug #194 — False clipboard claim
- Location: `apps/gateway/src/commands/command-executor.service.ts` - Location: `apps/gateway/src/commands/command-executor.service.ts`
- Fix: Removed the `\n\n(URL copied to clipboard)` suffix from the provider login message. - Fix: Removed the `\n\n(URL copied to clipboard)` suffix from the provider login message.
### Bug #199 — Hardcoded version "0.0.0" ### Bug #199 — Hardcoded version "0.0.0"
- Location: `packages/cli/src/cli.ts` + `packages/cli/src/tui/app.tsx` - Location: `packages/cli/src/cli.ts` + `packages/cli/src/tui/app.tsx`
- Fix: `cli.ts` reads version from `../package.json` via `createRequire`. Passes `version: CLI_VERSION` - Fix: `cli.ts` reads version from `../package.json` via `createRequire`. Passes `version: CLI_VERSION`
to TuiApp in both render calls. TuiApp has new optional `version` prop (defaults to '0.0.0'), to TuiApp in both render calls. TuiApp has new optional `version` prop (defaults to '0.0.0'),
passes it to TopBar instead of hardcoded `"0.0.0"`. passes it to TopBar instead of hardcoded `"0.0.0"`.
## Quality Gates ## Quality Gates
- CLI typecheck: PASSED - CLI typecheck: PASSED
- CLI lint: PASSED - CLI lint: PASSED
- Prettier format:check: PASSED - Prettier format:check: PASSED

View File

@@ -1,65 +0,0 @@
# P8-001 — WorkOS + Keycloak SSO Providers
**Branch:** feat/p8-001-sso-providers
**Started:** 2026-03-18
**Mode:** Delivery
## Objective
Add WorkOS and Keycloak as optional SSO providers to the BetterAuth configuration, following the existing Authentik pattern.
## Scope
| Surface | Change |
| ---------------------------------------- | ----------------------------------------------------------------------- |
| `packages/auth/src/auth.ts` | Refactor provider array, add WorkOS + Keycloak conditional registration |
| `apps/web/src/lib/auth-client.ts` | Add `genericOAuthClient()` plugin |
| `apps/web/src/app/(auth)/login/page.tsx` | WorkOS + Keycloak SSO buttons gated by `NEXT_PUBLIC_*` env vars |
| `.env.example` | Document WorkOS + Keycloak env vars |
| `packages/auth/src/auth.test.ts` | Unit tests verifying env-var gating |
## Plan
1. ✅ Refactor `createAuth` to build `oauthProviders[]` conditionally
2. ✅ Add WorkOS provider (explicit URLs, no discovery)
3. ✅ Add Keycloak provider (discoveryUrl pattern)
4. ✅ Add `genericOAuthClient()` to auth-client.ts
5. ✅ Add SSO buttons to login page gated by `NEXT_PUBLIC_WORKOS_ENABLED` / `NEXT_PUBLIC_KEYCLOAK_ENABLED`
6. ✅ Update `.env.example`
7. ⏳ Write `auth.test.ts` with env-var gating tests
8. ⏳ Quality gates: typecheck + lint + format:check + test
9. ⏳ Commit + push + PR
## Decisions
- **WorkOS**: Uses explicit `authorizationUrl`, `tokenUrl`, `userInfoUrl` (no discovery endpoint available)
- **Keycloak**: Uses `discoveryUrl` pattern (`{URL}/realms/{REALM}/.well-known/openid-configuration`)
- **UI gating**: Login page uses `NEXT_PUBLIC_WORKOS_ENABLED` / `NEXT_PUBLIC_KEYCLOAK_ENABLED` feature flags (safer than exposing secret env var names client-side)
- **Refactor**: Authentik moved into same `oauthProviders[]` array pattern — cleaner, more extensible
- **Feature flag design**: `NEXT_PUBLIC_*` flags are opt-in alongside credentials (prevents accidental button render when creds not set)
## Assumptions
- `ASSUMPTION:` WorkOS OIDC discovery URL is not publicly documented; using direct URL pattern from WorkOS SSO docs.
- `ASSUMPTION:` `NEXT_PUBLIC_WORKOS_ENABLED=true` must be explicitly set — this is intentional (credential presence alone doesn't enable the button since NEXT_PUBLIC vars are baked at build time).
## Tests
- `auth.test.ts`: Mocks betterAuth stack, verifies WorkOS included/excluded based on env var
- `auth.test.ts`: Verifies Keycloak discoveryUrl constructed correctly
## Quality Gate Results
| Gate | Status |
| ------------------- | -------------------------------------------- |
| typecheck | ✅ 32/32 cached green |
| lint | ✅ 18/18 cached green |
| format:check | ✅ All matched files use Prettier code style |
| test (@mosaic/auth) | ✅ 8/8 tests passed |
## Verification Evidence
- `pnpm typecheck` — FULL TURBO, 32 tasks successful
- `pnpm lint` — FULL TURBO, 18 tasks successful
- `pnpm format:check` — All matched files use Prettier code style!
- `pnpm --filter=@mosaic/auth test` — 8 tests passed, 0 failed

View File

@@ -1,161 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { buildOAuthProviders } from './auth.js';
describe('buildOAuthProviders', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
delete process.env['AUTHENTIK_CLIENT_ID'];
delete process.env['AUTHENTIK_CLIENT_SECRET'];
delete process.env['AUTHENTIK_ISSUER'];
delete process.env['WORKOS_CLIENT_ID'];
delete process.env['WORKOS_CLIENT_SECRET'];
delete process.env['WORKOS_ISSUER'];
delete process.env['KEYCLOAK_CLIENT_ID'];
delete process.env['KEYCLOAK_CLIENT_SECRET'];
delete process.env['KEYCLOAK_ISSUER'];
delete process.env['KEYCLOAK_URL'];
delete process.env['KEYCLOAK_REALM'];
});
afterEach(() => {
process.env = originalEnv;
});
it('returns empty array when no SSO env vars are set', () => {
const providers = buildOAuthProviders();
expect(providers).toHaveLength(0);
});
describe('WorkOS', () => {
it('includes workos provider when all required env vars are set', () => {
process.env['WORKOS_CLIENT_ID'] = 'client_test123';
process.env['WORKOS_CLIENT_SECRET'] = 'sk_live_test';
process.env['WORKOS_ISSUER'] = 'https://example.authkit.app/';
const providers = buildOAuthProviders();
const workos = providers.find((p) => p.providerId === 'workos');
expect(workos).toBeDefined();
expect(workos?.clientId).toBe('client_test123');
expect(workos?.issuer).toBe('https://example.authkit.app');
expect(workos?.discoveryUrl).toBe(
'https://example.authkit.app/.well-known/openid-configuration',
);
expect(workos?.scopes).toEqual(['openid', 'email', 'profile']);
});
it('throws when WorkOS is partially configured', () => {
process.env['WORKOS_CLIENT_ID'] = 'client_test123';
expect(() => buildOAuthProviders()).toThrow(
'@mosaic/auth: WorkOS SSO requires WORKOS_ISSUER, WORKOS_CLIENT_ID, WORKOS_CLIENT_SECRET.',
);
});
it('excludes workos provider when WorkOS is not configured', () => {
const providers = buildOAuthProviders();
const workos = providers.find((p) => p.providerId === 'workos');
expect(workos).toBeUndefined();
});
});
describe('Keycloak', () => {
it('includes keycloak provider when KEYCLOAK_ISSUER is set', () => {
process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic';
process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123';
process.env['KEYCLOAK_ISSUER'] = 'https://auth.example.com/realms/myrealm/';
const providers = buildOAuthProviders();
const keycloakProvider = providers.find((p) => p.providerId === 'keycloak');
expect(keycloakProvider).toBeDefined();
expect(keycloakProvider?.clientId).toBe('mosaic');
expect(keycloakProvider?.discoveryUrl).toBe(
'https://auth.example.com/realms/myrealm/.well-known/openid-configuration',
);
expect(keycloakProvider?.scopes).toEqual(['openid', 'email', 'profile']);
});
it('supports deriving the Keycloak issuer from KEYCLOAK_URL and KEYCLOAK_REALM', () => {
process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic';
process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123';
process.env['KEYCLOAK_URL'] = 'https://auth.example.com/';
process.env['KEYCLOAK_REALM'] = 'myrealm';
const providers = buildOAuthProviders();
const keycloakProvider = providers.find((p) => p.providerId === 'keycloak');
expect(keycloakProvider?.discoveryUrl).toBe(
'https://auth.example.com/realms/myrealm/.well-known/openid-configuration',
);
});
it('throws when Keycloak is partially configured', () => {
process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic';
process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123';
expect(() => buildOAuthProviders()).toThrow(
'@mosaic/auth: Keycloak SSO requires KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER.',
);
});
it('excludes keycloak provider when Keycloak is not configured', () => {
const providers = buildOAuthProviders();
const keycloakProvider = providers.find((p) => p.providerId === 'keycloak');
expect(keycloakProvider).toBeUndefined();
});
});
describe('Authentik', () => {
it('includes authentik provider when all required env vars are set', () => {
process.env['AUTHENTIK_CLIENT_ID'] = 'authentik-client';
process.env['AUTHENTIK_CLIENT_SECRET'] = 'authentik-secret';
process.env['AUTHENTIK_ISSUER'] = 'https://auth.example.com/application/o/mosaic/';
const providers = buildOAuthProviders();
const authentik = providers.find((p) => p.providerId === 'authentik');
expect(authentik).toBeDefined();
expect(authentik?.clientId).toBe('authentik-client');
expect(authentik?.issuer).toBe('https://auth.example.com/application/o/mosaic');
expect(authentik?.discoveryUrl).toBe(
'https://auth.example.com/application/o/mosaic/.well-known/openid-configuration',
);
});
it('throws when Authentik is partially configured', () => {
process.env['AUTHENTIK_CLIENT_ID'] = 'authentik-client';
expect(() => buildOAuthProviders()).toThrow(
'@mosaic/auth: Authentik SSO requires AUTHENTIK_ISSUER, AUTHENTIK_CLIENT_ID, AUTHENTIK_CLIENT_SECRET.',
);
});
it('excludes authentik provider when Authentik is not configured', () => {
const providers = buildOAuthProviders();
const authentik = providers.find((p) => p.providerId === 'authentik');
expect(authentik).toBeUndefined();
});
});
it('registers all three providers when all env vars are set', () => {
process.env['AUTHENTIK_CLIENT_ID'] = 'a-id';
process.env['AUTHENTIK_CLIENT_SECRET'] = 'a-secret';
process.env['AUTHENTIK_ISSUER'] = 'https://auth.example.com/application/o/mosaic';
process.env['WORKOS_CLIENT_ID'] = 'w-id';
process.env['WORKOS_CLIENT_SECRET'] = 'w-secret';
process.env['WORKOS_ISSUER'] = 'https://example.authkit.app';
process.env['KEYCLOAK_CLIENT_ID'] = 'k-id';
process.env['KEYCLOAK_CLIENT_SECRET'] = 'k-secret';
process.env['KEYCLOAK_ISSUER'] = 'https://kc.example.com/realms/test';
const providers = buildOAuthProviders();
expect(providers).toHaveLength(3);
const ids = providers.map((p) => p.providerId);
expect(ids).toContain('authentik');
expect(ids).toContain('workos');
expect(ids).toContain('keycloak');
});
});

View File

@@ -1,7 +1,6 @@
import { betterAuth } from 'better-auth'; import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { admin } from 'better-auth/plugins'; import { admin, genericOAuth } from 'better-auth/plugins';
import { genericOAuth, keycloak, type GenericOAuthConfig } from 'better-auth/plugins/generic-oauth';
import type { Db } from '@mosaic/db'; import type { Db } from '@mosaic/db';
export interface AuthConfig { export interface AuthConfig {
@@ -10,118 +9,35 @@ export interface AuthConfig {
secret?: string; secret?: string;
} }
/** Builds the list of enabled OAuth providers from environment variables. Exported for testing. */
export function buildOAuthProviders(): GenericOAuthConfig[] {
const providers: GenericOAuthConfig[] = [];
const authentikIssuer = normalizeIssuer(process.env['AUTHENTIK_ISSUER']);
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
const authentikClientSecret = process.env['AUTHENTIK_CLIENT_SECRET'];
assertOptionalProviderConfig('Authentik SSO', {
required: ['AUTHENTIK_ISSUER', 'AUTHENTIK_CLIENT_ID', 'AUTHENTIK_CLIENT_SECRET'],
values: [authentikIssuer, authentikClientId, authentikClientSecret],
});
if (authentikIssuer && authentikClientId && authentikClientSecret) {
providers.push({
providerId: 'authentik',
clientId: authentikClientId,
clientSecret: authentikClientSecret,
issuer: authentikIssuer,
discoveryUrl: buildDiscoveryUrl(authentikIssuer),
scopes: ['openid', 'email', 'profile'],
requireIssuerValidation: true,
});
}
const workosIssuer = normalizeIssuer(process.env['WORKOS_ISSUER']);
const workosClientId = process.env['WORKOS_CLIENT_ID'];
const workosClientSecret = process.env['WORKOS_CLIENT_SECRET'];
assertOptionalProviderConfig('WorkOS SSO', {
required: ['WORKOS_ISSUER', 'WORKOS_CLIENT_ID', 'WORKOS_CLIENT_SECRET'],
values: [workosIssuer, workosClientId, workosClientSecret],
});
if (workosIssuer && workosClientId && workosClientSecret) {
providers.push({
providerId: 'workos',
clientId: workosClientId,
clientSecret: workosClientSecret,
issuer: workosIssuer,
discoveryUrl: buildDiscoveryUrl(workosIssuer),
scopes: ['openid', 'email', 'profile'],
requireIssuerValidation: true,
});
}
const keycloakIssuer = resolveKeycloakIssuer();
const keycloakClientId = process.env['KEYCLOAK_CLIENT_ID'];
const keycloakClientSecret = process.env['KEYCLOAK_CLIENT_SECRET'];
assertOptionalProviderConfig('Keycloak SSO', {
required: ['KEYCLOAK_CLIENT_ID', 'KEYCLOAK_CLIENT_SECRET', 'KEYCLOAK_ISSUER'],
values: [keycloakClientId, keycloakClientSecret, keycloakIssuer],
});
if (keycloakIssuer && keycloakClientId && keycloakClientSecret) {
providers.push(
keycloak({
clientId: keycloakClientId,
clientSecret: keycloakClientSecret,
issuer: keycloakIssuer,
scopes: ['openid', 'email', 'profile'],
}),
);
}
return providers;
}
function resolveKeycloakIssuer(): string | undefined {
const issuer = normalizeIssuer(process.env['KEYCLOAK_ISSUER']);
if (issuer) {
return issuer;
}
const baseUrl = normalizeIssuer(process.env['KEYCLOAK_URL']);
const realm = process.env['KEYCLOAK_REALM']?.trim();
if (!baseUrl || !realm) {
return undefined;
}
return `${baseUrl}/realms/${realm}`;
}
function buildDiscoveryUrl(issuer: string): string {
return `${issuer}/.well-known/openid-configuration`;
}
function normalizeIssuer(value: string | undefined): string | undefined {
return value?.trim().replace(/\/+$/, '') || undefined;
}
function assertOptionalProviderConfig(
providerName: string,
config: { required: string[]; values: Array<string | undefined> },
): void {
const hasAnyValue = config.values.some((value) => Boolean(value?.trim()));
const hasAllValues = config.values.every((value) => Boolean(value?.trim()));
if (!hasAnyValue || hasAllValues) {
return;
}
throw new Error(
`@mosaic/auth: ${providerName} requires ${config.required.join(', ')}. Set them in your config or via the listed environment variables.`,
);
}
export function createAuth(config: AuthConfig) { export function createAuth(config: AuthConfig) {
const { db, baseURL, secret } = config; const { db, baseURL, secret } = config;
const authentikIssuer = process.env['AUTHENTIK_ISSUER'];
const oauthProviders = buildOAuthProviders(); const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
const plugins = const authentikClientSecret = process.env['AUTHENTIK_CLIENT_SECRET'];
oauthProviders.length > 0 ? [genericOAuth({ config: oauthProviders })] : undefined; const plugins = authentikClientId
? [
genericOAuth({
config: [
{
providerId: 'authentik',
clientId: authentikClientId,
clientSecret: authentikClientSecret ?? '',
discoveryUrl: authentikIssuer
? `${authentikIssuer}/.well-known/openid-configuration`
: undefined,
authorizationUrl: authentikIssuer
? `${authentikIssuer}/application/o/authorize/`
: undefined,
tokenUrl: authentikIssuer ? `${authentikIssuer}/application/o/token/` : undefined,
userInfoUrl: authentikIssuer
? `${authentikIssuer}/application/o/userinfo/`
: undefined,
scopes: ['openid', 'email', 'profile'],
},
],
}),
]
: undefined;
const corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000'; const corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000';
const trustedOrigins = corsOrigin.split(',').map((o) => o.trim()); const trustedOrigins = corsOrigin.split(',').map((o) => o.trim());

View File

@@ -1,9 +1,4 @@
import { eq, asc, desc, type Db, conversations, messages } from '@mosaic/db'; import { eq, type Db, conversations, messages } from '@mosaic/db';
/** Maximum number of conversations returned per list query. */
const MAX_CONVERSATIONS = 200;
/** Maximum number of messages returned per conversation history query. */
const MAX_MESSAGES = 500;
export type Conversation = typeof conversations.$inferSelect; export type Conversation = typeof conversations.$inferSelect;
export type NewConversation = typeof conversations.$inferInsert; export type NewConversation = typeof conversations.$inferInsert;
@@ -13,12 +8,7 @@ export type NewMessage = typeof messages.$inferInsert;
export function createConversationsRepo(db: Db) { export function createConversationsRepo(db: Db) {
return { return {
async findAll(userId: string): Promise<Conversation[]> { async findAll(userId: string): Promise<Conversation[]> {
return db return db.select().from(conversations).where(eq(conversations.userId, userId));
.select()
.from(conversations)
.where(eq(conversations.userId, userId))
.orderBy(desc(conversations.updatedAt))
.limit(MAX_CONVERSATIONS);
}, },
async findById(id: string): Promise<Conversation | undefined> { async findById(id: string): Promise<Conversation | undefined> {
@@ -46,12 +36,7 @@ export function createConversationsRepo(db: Db) {
}, },
async findMessages(conversationId: string): Promise<Message[]> { async findMessages(conversationId: string): Promise<Message[]> {
return db return db.select().from(messages).where(eq(messages.conversationId, conversationId));
.select()
.from(messages)
.where(eq(messages.conversationId, conversationId))
.orderBy(asc(messages.createdAt))
.limit(MAX_MESSAGES);
}, },
async addMessage(data: NewMessage): Promise<Message> { async addMessage(data: NewMessage): Promise<Message> {

View File

@@ -1,14 +0,0 @@
DROP INDEX "agent_logs_session_id_idx";--> statement-breakpoint
DROP INDEX "agent_logs_tier_idx";--> statement-breakpoint
DROP INDEX "agent_logs_created_at_idx";--> statement-breakpoint
DROP INDEX "conversations_user_id_idx";--> statement-breakpoint
DROP INDEX "conversations_archived_idx";--> statement-breakpoint
DROP INDEX "preferences_user_key_idx";--> statement-breakpoint
CREATE INDEX "accounts_provider_account_idx" ON "accounts" USING btree ("provider_id","account_id");--> statement-breakpoint
CREATE INDEX "accounts_user_id_idx" ON "accounts" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "agent_logs_session_tier_idx" ON "agent_logs" USING btree ("session_id","tier");--> statement-breakpoint
CREATE INDEX "agent_logs_tier_created_at_idx" ON "agent_logs" USING btree ("tier","created_at");--> statement-breakpoint
CREATE INDEX "conversations_user_archived_idx" ON "conversations" USING btree ("user_id","archived");--> statement-breakpoint
CREATE INDEX "sessions_user_id_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "sessions_expires_at_idx" ON "sessions" USING btree ("expires_at");--> statement-breakpoint
CREATE UNIQUE INDEX "preferences_user_key_idx" ON "preferences" USING btree ("user_id","key");

File diff suppressed because it is too large Load Diff

View File

@@ -22,13 +22,6 @@
"when": 1773625181629, "when": 1773625181629,
"tag": "0002_nebulous_mimic", "tag": "0002_nebulous_mimic",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1773887085247,
"tag": "0003_p8003_perf_indexes",
"breakpoints": true
} }
] ]
} }

View File

@@ -12,15 +12,7 @@ export interface DbHandle {
export function createDb(url?: string): DbHandle { export function createDb(url?: string): DbHandle {
const connectionString = url ?? process.env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL; const connectionString = url ?? process.env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL;
const sql = postgres(connectionString, { const sql = postgres(connectionString);
// Pool sizing: allow up to 20 concurrent connections per gateway instance.
// Each NestJS module (brain, preferences, memory, coord) shares this pool.
max: Number(process.env['DB_POOL_MAX'] ?? 20),
// Recycle idle connections after 30 s to avoid stale TCP state.
idle_timeout: Number(process.env['DB_IDLE_TIMEOUT'] ?? 30),
// Fail fast (5 s) on connection problems rather than hanging indefinitely.
connect_timeout: Number(process.env['DB_CONNECT_TIMEOUT'] ?? 5),
});
const db = drizzle(sql, { schema }); const db = drizzle(sql, { schema });
return { db, close: () => sql.end() }; return { db, close: () => sql.end() };
} }

View File

@@ -33,54 +33,36 @@ export const users = pgTable('users', {
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}); });
export const sessions = pgTable( export const sessions = pgTable('sessions', {
'sessions', id: text('id').primaryKey(),
{ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
id: text('id').primaryKey(), token: text('token').notNull().unique(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), ipAddress: text('ip_address'),
token: text('token').notNull().unique(), userAgent: text('user_agent'),
ipAddress: text('ip_address'), userId: text('user_id')
userAgent: text('user_agent'), .notNull()
userId: text('user_id') .references(() => users.id, { onDelete: 'cascade' }),
.notNull() createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
.references(() => users.id, { onDelete: 'cascade' }), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), });
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
// Auth hot path: look up all sessions for a user (BetterAuth session list).
index('sessions_user_id_idx').on(t.userId),
// Session expiry cleanup queries.
index('sessions_expires_at_idx').on(t.expiresAt),
],
);
export const accounts = pgTable( export const accounts = pgTable('accounts', {
'accounts', id: text('id').primaryKey(),
{ accountId: text('account_id').notNull(),
id: text('id').primaryKey(), providerId: text('provider_id').notNull(),
accountId: text('account_id').notNull(), userId: text('user_id')
providerId: text('provider_id').notNull(), .notNull()
userId: text('user_id') .references(() => users.id, { onDelete: 'cascade' }),
.notNull() accessToken: text('access_token'),
.references(() => users.id, { onDelete: 'cascade' }), refreshToken: text('refresh_token'),
accessToken: text('access_token'), idToken: text('id_token'),
refreshToken: text('refresh_token'), accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }),
idToken: text('id_token'), refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }),
accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }), scope: text('scope'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }), password: text('password'),
scope: text('scope'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
password: text('password'), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), });
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
// BetterAuth looks up accounts by (provider_id, account_id) on OAuth callback.
index('accounts_provider_account_idx').on(t.providerId, t.accountId),
// Also used in session validation to find linked accounts for a user.
index('accounts_user_id_idx').on(t.userId),
],
);
export const verifications = pgTable('verifications', { export const verifications = pgTable('verifications', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
@@ -324,10 +306,10 @@ export const conversations = pgTable(
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, },
(t) => [ (t) => [
// Compound index for the most common query: conversations for a user filtered by archived. index('conversations_user_id_idx').on(t.userId),
index('conversations_user_archived_idx').on(t.userId, t.archived),
index('conversations_project_id_idx').on(t.projectId), index('conversations_project_id_idx').on(t.projectId),
index('conversations_agent_id_idx').on(t.agentId), index('conversations_agent_id_idx').on(t.agentId),
index('conversations_archived_idx').on(t.archived),
], ],
); );
@@ -387,8 +369,7 @@ export const preferences = pgTable(
}, },
(t) => [ (t) => [
index('preferences_user_id_idx').on(t.userId), index('preferences_user_id_idx').on(t.userId),
// Unique constraint enables single-round-trip INSERT … ON CONFLICT DO UPDATE. index('preferences_user_key_idx').on(t.userId, t.key),
uniqueIndex('preferences_user_key_idx').on(t.userId, t.key),
], ],
); );
@@ -450,11 +431,10 @@ export const agentLogs = pgTable(
archivedAt: timestamp('archived_at', { withTimezone: true }), archivedAt: timestamp('archived_at', { withTimezone: true }),
}, },
(t) => [ (t) => [
// Compound index for session log queries (most common: session + tier filter). index('agent_logs_session_id_idx').on(t.sessionId),
index('agent_logs_session_tier_idx').on(t.sessionId, t.tier),
index('agent_logs_user_id_idx').on(t.userId), index('agent_logs_user_id_idx').on(t.userId),
// Used by summarization cron to find hot logs older than a cutoff. index('agent_logs_tier_idx').on(t.tier),
index('agent_logs_tier_created_at_idx').on(t.tier, t.createdAt), index('agent_logs_created_at_idx').on(t.createdAt),
], ],
); );

662
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff