Compare commits
3 Commits
6c498c5f40
...
c5e78311e6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5e78311e6 | ||
|
|
60b76c6d97 | ||
|
|
d12681b79f |
139
.claude/CLAUDE.md
Normal file
139
.claude/CLAUDE.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
source ~/.nvm/nvm.sh && npm start # Start Electron app with hot-reload
|
||||||
|
|
||||||
|
# Build & Package
|
||||||
|
source ~/.nvm/nvm.sh && npm run make # Build distributable packages
|
||||||
|
source ~/.nvm/nvm.sh && npm run package # Package without making installers
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
source ~/.nvm/nvm.sh && npm run lint # ESLint over .ts/.tsx files
|
||||||
|
|
||||||
|
# Database migrations (Drizzle)
|
||||||
|
source ~/.nvm/nvm.sh && npx drizzle-kit generate # Generate migration from schema changes
|
||||||
|
source ~/.nvm/nvm.sh && npx drizzle-kit push # Push schema directly (dev only)
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no test suite currently.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
Adiuva is a local-first Electron desktop app. The three Electron processes communicate via a custom tRPC↔IPC bridge (the public `electron-trpc` package is incompatible with tRPC v11, so a custom implementation is used).
|
||||||
|
|
||||||
|
### Process Boundaries
|
||||||
|
|
||||||
|
```
|
||||||
|
Renderer (React) ──ipcLink──► Preload (contextBridge) ──IPC──► Main (tRPC router + SQLite)
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Main process** (`src/main/`) — Node.js, owns the database and all business logic
|
||||||
|
- `index.ts` — Window creation, app lifecycle
|
||||||
|
- `ipc.ts` — Custom handler that bridges `ipcMain` to tRPC procedures
|
||||||
|
- `router/index.ts` — All tRPC routers (clients, projects, tasks, checkpoints, notes, settings, ai)
|
||||||
|
- `db/index.ts` — Drizzle + better-sqlite3, WAL mode, singleton `getDb()`
|
||||||
|
- `db/schema.ts` — All table definitions (clients, projects, tasks, checkpoints, notes)
|
||||||
|
- `store.ts` — electron-store for persistent UI settings (e.g., `sidebarCollapsed`)
|
||||||
|
|
||||||
|
2. **Preload** (`src/preload/trpc.ts`) — Exposes `window.electronTRPC` with `sendMessage()` / `onMessage()`
|
||||||
|
|
||||||
|
3. **Renderer** (`src/renderer/`) — React 19, never accesses Node APIs directly
|
||||||
|
- `lib/ipcLink.ts` — Custom TRPCLink that routes calls through `window.electronTRPC`
|
||||||
|
- `lib/trpc.ts` — `createTRPCReact<AppRouter>()` typed client
|
||||||
|
- `index.tsx` — QueryClient + tRPC + Router providers
|
||||||
|
- All data access is through `trpc.*.*useQuery()` / `trpc.*.*.useMutation()`
|
||||||
|
|
||||||
|
### Routing
|
||||||
|
|
||||||
|
File-based routing via TanStack Router. Add a file to `src/renderer/routes/` and the route tree (`src/renderer/routeTree.gen.ts`) is auto-regenerated by the Vite plugin on next `npm start`. Routes:
|
||||||
|
- `__root.tsx` — Root layout wrapping everything in `AppShell`
|
||||||
|
- `index.tsx`, `tasks.tsx`, `timeline.tsx`, `projects.tsx`
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
Schema lives in `src/main/db/schema.ts`. Migrations are in `src/main/db/migrations/`. The DB is created in Electron's `userData` directory as `adiuva.db`. On startup, `initDb()` runs non-destructive migrations (CREATE TABLE IF NOT EXISTS).
|
||||||
|
|
||||||
|
To add a new table or column: edit `schema.ts`, run `drizzle-kit generate`, then `drizzle-kit push` (dev) or commit the migration file.
|
||||||
|
|
||||||
|
### Adding a New Feature (end-to-end pattern)
|
||||||
|
|
||||||
|
1. **Schema** — Add table/columns to `src/main/db/schema.ts`
|
||||||
|
2. **Router** — Add a tRPC sub-router in `src/main/router/index.ts`, merge it into `appRouter`
|
||||||
|
3. **Types** — `AppRouter` is exported from `src/main/router/index.ts` and imported in `src/renderer/lib/trpc.ts` — types flow automatically
|
||||||
|
4. **UI** — Create components under `src/renderer/components/<feature>/`, use `trpc.*.*useQuery()` for data
|
||||||
|
|
||||||
|
### AI Subsystem (`src/main/ai/`)
|
||||||
|
|
||||||
|
LangGraph-based agentic system with pluggable LLM providers (OpenAI, Anthropic, GitHub Copilot).
|
||||||
|
|
||||||
|
**Orchestrator** (`orchestrator.ts`): Classifies user intent → routes to one of three specialist agents:
|
||||||
|
- **Project agent** — project-scoped Q&A with tools: `read_project_notes`, `add_task`, `get_summary`, `suggest_checkpoints`, `suggest_tasks`
|
||||||
|
- **Knowledge agent** — cross-project semantic search via `vector_search_all`
|
||||||
|
- **General agent** — workspace-wide `add_task`
|
||||||
|
|
||||||
|
Tool-calling strategy differs by provider: OpenAI/Anthropic use LangChain `bindTools()` + ToolMessage loop (max 5 iterations); Copilot uses SDK-native tools (loop handled internally).
|
||||||
|
|
||||||
|
**Streaming**: Orchestrator calls `sendStreamChunk(sender, token, done)` over IPC channel `'ai:stream'`. Renderer subscribes via `window.electronAI.onStreamChunk()` in `AIChatPanel.tsx`. `<tool_call>` blocks are filtered before sending to renderer.
|
||||||
|
|
||||||
|
**Provider factory** (`llm.ts`): `gpt-4o-mini` (OpenAI), `claude-sonnet-4-20250514` (Anthropic), or ChatCopilot wrapper — all with `temperature: 0.3` and streaming enabled.
|
||||||
|
|
||||||
|
**Token storage** (`token.ts`) — three-tier fallback:
|
||||||
|
1. keytar (OS keychain) — preferred, encrypted per-user
|
||||||
|
2. electron-store + `safeStorage` — encrypted at rest
|
||||||
|
3. Plain electron-store — WSL fallback
|
||||||
|
|
||||||
|
Keytar service name is `'adiuva'`. Once keytar fails, `keytarFailed` flag skips it for the session.
|
||||||
|
|
||||||
|
**AI approval pattern**: Tasks and checkpoints have `isAiSuggested` (bool) and `isApproved` (bool) columns. AI-suggested items appear in the UI pending user approval before being treated as real records.
|
||||||
|
|
||||||
|
### Vector Embeddings (`src/main/db/vectordb.ts`)
|
||||||
|
|
||||||
|
LanceDB stored in `{userData}/vectors/`. Table schema: `{ id, projectId, content, vector }`. Vectors are 1536-dimensional (`text-embedding-3-small`). Embeddings use a priority chain: Copilot CLI token → OpenAI token.
|
||||||
|
|
||||||
|
- Note create/update fires `upsertNoteEmbedding()` (fire-and-forget, errors swallowed)
|
||||||
|
- `migrateNotesIfNeeded()` backfills existing notes on first startup
|
||||||
|
- `searchNotes(query, limit=5)` is called by the Knowledge agent tool
|
||||||
|
|
||||||
|
### Key Config Notes
|
||||||
|
|
||||||
|
- Vite configs use `.mts` extension (not `.ts`) to avoid ESM/CJS conflicts with electron-forge's externalize-deps plugin
|
||||||
|
- `@/*` path alias resolves to `src/renderer/*` (TypeScript + Vite + shadcn/ui all share this alias)
|
||||||
|
- shadcn/ui style: **new-york**, base color: **neutral**
|
||||||
|
- Icons: **lucide-react** throughout — do not introduce other icon libraries
|
||||||
|
- Tailwind 4 (not 3) — use CSS variable theming via `globals.css`, not `tailwind.config.js`
|
||||||
|
- Notes use Milkdown (`@milkdown/crepe`) as the markdown editor (`src/renderer/components/notes/MilkdownEditor.tsx`)
|
||||||
|
- Routes: `index`, `tasks`, `timeline`, `projects`, `notes.$noteId` (note ID is a URL param)
|
||||||
|
|
||||||
|
## Design Context
|
||||||
|
|
||||||
|
### Users
|
||||||
|
Freelancers and solo professionals managing their own client work — projects, tasks, notes, and timelines. They work alone and need a single workspace that keeps everything organized without the overhead of enterprise tools. The AI assistant is a force multiplier, helping them stay on top of their workload.
|
||||||
|
|
||||||
|
### Brand Personality
|
||||||
|
**Calm, intelligent, warm.** Adiuva is a thoughtful companion, not a flashy tool. It should feel like a well-organized desk — everything in its place, nothing competing for attention. The tone is confident and understated, never loud or gamified.
|
||||||
|
|
||||||
|
### Aesthetic Direction
|
||||||
|
- **Visual tone**: Editorial, premium, content-first. Inspired by Notion's clean typography and warm neutrals, but with a distinct identity through the warm pinkish-white canvas and golden yellow accent
|
||||||
|
- **Light mode**: Soft and warm — pinkish-white (`#f4edf3`) canvas, golden yellow (`#fbc881`) primary, slate blue-gray (`#8a8ea9`) secondary, dusty lavender borders (`#c8c3cd`)
|
||||||
|
- **Dark mode**: Stark monochrome — near-black canvas (`#0c0c0c`), crisp white text, dark gray surfaces (`#323232`). No color accent; primary is pure white
|
||||||
|
- **Typography**: Geist (geometric sans-serif) at 400/500/600. Tight tracking on large headings (`-1px`). Body at `text-sm`, metadata at `text-xs`
|
||||||
|
- **Corners**: 10px base radius, consistently rounded. Chat elements use `rounded-2xl`
|
||||||
|
- **Signature effects**: Glassmorphism on AI inputs/floating chat (`backdrop-blur-xl`, transparency). Spring physics animations (stiffness 400, damping 30). Subtle scale-and-fade transitions
|
||||||
|
- **Anti-references**: No gamification (badges, streaks, confetti). No corporate/enterprise density. Keep it mature and professional
|
||||||
|
|
||||||
|
### Design Principles
|
||||||
|
|
||||||
|
1. **Clarity over cleverness** — Every element should communicate its purpose instantly. Prefer clear hierarchy and whitespace over decorative flourish. Information density should feel comfortable, not cramped.
|
||||||
|
|
||||||
|
2. **AI as quiet partner** — The AI is deeply integrated (floating chat, suggestions) but never intrusive. AI-suggested items use dashed borders to signal "pending." The Sparkles icon is the consistent AI identity marker.
|
||||||
|
|
||||||
|
3. **Warmth in restraint** — The palette is deliberately warm (pinkish whites, golden yellows) to feel approachable without being playful. Dark mode trades warmth for focus. Let the content breathe.
|
||||||
|
|
||||||
|
4. **Motion with purpose** — Spring physics and glassmorphism create a sense of physicality and depth. Animations should feel natural and responsive, never decorative or slow. Every transition should reinforce spatial relationships.
|
||||||
|
|
||||||
|
5. **Confidence through consistency** — Use the established token system (CSS variables, shadcn/ui primitives, Geist font). The user should feel in control — predictable patterns, keyboard-first interactions, no surprises.
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -93,4 +93,4 @@ out/
|
|||||||
|
|
||||||
# local config files
|
# local config files
|
||||||
.vscode/
|
.vscode/
|
||||||
.claude/
|
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ Steps MUST be implemented in order. Each step lists its prerequisites.
|
|||||||
| 2 | Create section registry + `FloatingChatContext` | [x] 2026-02-27 |
|
| 2 | Create section registry + `FloatingChatContext` | [x] 2026-02-27 |
|
||||||
| 3 | Create double-click hook | [x] 2026-02-27 |
|
| 3 | Create double-click hook | [x] 2026-02-27 |
|
||||||
| 4 | Build `FloatingChat` component | [x] 2026-02-27 |
|
| 4 | Build `FloatingChat` component | [x] 2026-02-27 |
|
||||||
| 5 | Add `ai:action` IPC side-channel | [ ] |
|
| 5 | Add `ai:action` IPC side-channel | [x] 2026-02-28 |
|
||||||
| 6 | Pass `uiContext` through to the AI | [ ] |
|
| 6 | Pass `uiContext` through to the AI | [x] 2026-02-28 |
|
||||||
| 7 | Implement morph animation (FLIP) | [ ] |
|
| 7 | Implement morph animation (FLIP) | [x] 2026-02-28 |
|
||||||
| 8a | Page interactions — Project Detail | [ ] |
|
| 8a | Page interactions — Project Detail | [ ] |
|
||||||
| 8b | Page interactions — Tasks page | [ ] |
|
| 8b | Page interactions — Tasks page | [ ] |
|
||||||
| 8c | Page interactions — Timeline page | [ ] |
|
| 8c | Page interactions — Timeline page | [ ] |
|
||||||
@@ -740,7 +740,7 @@ currentSender = sender;
|
|||||||
|
|
||||||
## Step 6: Pass `uiContext` Through to the AI
|
## Step 6: Pass `uiContext` Through to the AI
|
||||||
|
|
||||||
**Status**: [ ]
|
**Status**: [x] (2026-02-28)
|
||||||
**Prerequisites**: Step 5 completed
|
**Prerequisites**: Step 5 completed
|
||||||
**Modifies**:
|
**Modifies**:
|
||||||
- `src/main/router/index.ts` (line ~550-556)
|
- `src/main/router/index.ts` (line ~550-556)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ let currentSender: Electron.WebContents | undefined;
|
|||||||
|
|
||||||
export interface OrchestrateInput {
|
export interface OrchestrateInput {
|
||||||
message: string;
|
message: string;
|
||||||
context: { type: 'global' | 'project'; projectId?: string };
|
context: { type: 'global' | 'project'; projectId?: string; uiContext?: string };
|
||||||
sender?: Electron.WebContents;
|
sender?: Electron.WebContents;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,7 +445,7 @@ function buildKnowledgeTools(): StructuredTool[] {
|
|||||||
// System prompts
|
// System prompts
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function makeProjectAgentPrompt(contextData: string, withTools = true): string {
|
function makeProjectAgentPrompt(contextData: string, withTools = true, uiContext?: string): string {
|
||||||
const toolsSection = withTools ? `
|
const toolsSection = withTools ? `
|
||||||
You also have access to the following tools — use them proactively when appropriate:
|
You also have access to the following tools — use them proactively when appropriate:
|
||||||
- read_project_notes: Fetch full untruncated note content. Use for detailed note questions.
|
- read_project_notes: Fetch full untruncated note content. Use for detailed note questions.
|
||||||
@@ -464,10 +464,10 @@ ${contextData}
|
|||||||
${toolsSection}
|
${toolsSection}
|
||||||
Answer the user's question based on this project context. Be concise and helpful.
|
Answer the user's question based on this project context. Be concise and helpful.
|
||||||
When referencing tasks, notes, or checkpoints, mention them by name.
|
When referencing tasks, notes, or checkpoints, mention them by name.
|
||||||
If you don't have enough information, say so.`;
|
If you don't have enough information, say so.${uiContext ? `\nThe user is currently viewing the "${uiContext}" section of the UI.\nIf your response relates to a different section (e.g., user asks about checkpoints while viewing Tasks), prefix your response with [SECTION:<section-id>] where section-id matches one of: project-summary, project-timeline, project-tasks, project-notes, tasks-overview, tasks-list, timeline-chart, note-editor.\nOnly use this prefix when the answer clearly belongs in a different section than where the user currently is.` : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeGeneralAgentPrompt(contextData: string, withTools = true): string {
|
function makeGeneralAgentPrompt(contextData: string, withTools = true, uiContext?: string): string {
|
||||||
const toolsSection = withTools ? `
|
const toolsSection = withTools ? `
|
||||||
You also have access to the following tools — use them proactively when appropriate:
|
You also have access to the following tools — use them proactively when appropriate:
|
||||||
- add_task: Create a new task. Use whenever the user asks to add, register, or note a to-do item or task.
|
- add_task: Create a new task. Use whenever the user asks to add, register, or note a to-do item or task.
|
||||||
@@ -481,10 +481,10 @@ You have access to the following workspace data:
|
|||||||
${contextData}
|
${contextData}
|
||||||
${toolsSection}
|
${toolsSection}
|
||||||
Help the user with their question based on this workspace context. Provide concise, actionable answers.
|
Help the user with their question based on this workspace context. Provide concise, actionable answers.
|
||||||
When discussing tasks or projects, reference them by name.`;
|
When discussing tasks or projects, reference them by name.${uiContext ? `\nThe user is currently viewing the "${uiContext}" section of the UI.\nIf your response relates to a different section (e.g., user asks about checkpoints while viewing Tasks), prefix your response with [SECTION:<section-id>] where section-id matches one of: project-summary, project-timeline, project-tasks, project-notes, tasks-overview, tasks-list, timeline-chart, note-editor.\nOnly use this prefix when the answer clearly belongs in a different section than where the user currently is.` : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeKnowledgeAgentPrompt(contextData: string, withTools = true): string {
|
function makeKnowledgeAgentPrompt(contextData: string, withTools = true, uiContext?: string): string {
|
||||||
const toolsSection = withTools ? `
|
const toolsSection = withTools ? `
|
||||||
You have access to the following tools — use them proactively:
|
You have access to the following tools — use them proactively:
|
||||||
- vector_search_all: Performs semantic search across ALL project notes. Always use this tool when the user asks a knowledge question. Pass the user's question (or a refined version) as the query.
|
- vector_search_all: Performs semantic search across ALL project notes. Always use this tool when the user asks a knowledge question. Pass the user's question (or a refined version) as the query.
|
||||||
@@ -504,7 +504,7 @@ ${contextData}
|
|||||||
${toolsSection}
|
${toolsSection}
|
||||||
Your primary job is to find and synthesize information from notes across all projects.
|
Your primary job is to find and synthesize information from notes across all projects.
|
||||||
Always use the vector_search_all tool to search for relevant notes before answering.
|
Always use the vector_search_all tool to search for relevant notes before answering.
|
||||||
If no results are found, say so clearly.`;
|
If no results are found, say so clearly.${uiContext ? `\nThe user is currently viewing the "${uiContext}" section of the UI.\nIf your response relates to a different section (e.g., user asks about checkpoints while viewing Tasks), prefix your response with [SECTION:<section-id>] where section-id matches one of: project-summary, project-timeline, project-tasks, project-notes, tasks-overview, tasks-list, timeline-chart, note-editor.\nOnly use this prefix when the answer clearly belongs in a different section than where the user currently is.` : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -515,7 +515,7 @@ const OrchestratorState = Annotation.Root({
|
|||||||
/** The user's original message */
|
/** The user's original message */
|
||||||
userMessage: Annotation<string>(),
|
userMessage: Annotation<string>(),
|
||||||
/** Chat context (global vs project-scoped) */
|
/** Chat context (global vs project-scoped) */
|
||||||
chatContext: Annotation<{ type: 'global' | 'project'; projectId?: string }>(),
|
chatContext: Annotation<{ type: 'global' | 'project'; projectId?: string; uiContext?: string }>(),
|
||||||
/** The route chosen by the orchestrator */
|
/** The route chosen by the orchestrator */
|
||||||
route: Annotation<'project' | 'knowledge' | 'general'>(),
|
route: Annotation<'project' | 'knowledge' | 'general'>(),
|
||||||
/** Messages for the specialist agent */
|
/** Messages for the specialist agent */
|
||||||
@@ -583,7 +583,8 @@ async function projectAgent(state: State): Promise<Partial<State>> {
|
|||||||
// Including text tool descriptions in the system prompt causes the model to output
|
// Including text tool descriptions in the system prompt causes the model to output
|
||||||
// XML <tool_call> blocks instead of using the SDK's API-level mechanism.
|
// XML <tool_call> blocks instead of using the SDK's API-level mechanism.
|
||||||
const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot';
|
const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot';
|
||||||
const systemPrompt = makeProjectAgentPrompt(contextData, includeToolsInPrompt);
|
const uiContext = state.chatContext.uiContext;
|
||||||
|
const systemPrompt = makeProjectAgentPrompt(contextData, includeToolsInPrompt, uiContext);
|
||||||
|
|
||||||
if (!supportsTools) {
|
if (!supportsTools) {
|
||||||
console.log('[Orchestrator] projectAgent: using context-only fallback (no tool support)');
|
console.log('[Orchestrator] projectAgent: using context-only fallback (no tool support)');
|
||||||
@@ -671,7 +672,8 @@ async function knowledgeAgent(state: State): Promise<Partial<State>> {
|
|||||||
|
|
||||||
const supportsTools = TOOL_CALLING_PROVIDERS.has(getActiveProviderName());
|
const supportsTools = TOOL_CALLING_PROVIDERS.has(getActiveProviderName());
|
||||||
const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot';
|
const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot';
|
||||||
const systemPrompt = makeKnowledgeAgentPrompt(contextData, includeToolsInPrompt);
|
const uiContext = state.chatContext.uiContext;
|
||||||
|
const systemPrompt = makeKnowledgeAgentPrompt(contextData, includeToolsInPrompt, uiContext);
|
||||||
|
|
||||||
console.log(`[Orchestrator] knowledgeAgent: provider="${getActiveProviderName()}", supportsTools=${supportsTools}`);
|
console.log(`[Orchestrator] knowledgeAgent: provider="${getActiveProviderName()}", supportsTools=${supportsTools}`);
|
||||||
|
|
||||||
@@ -749,7 +751,8 @@ async function generalAgent(state: State): Promise<Partial<State>> {
|
|||||||
|
|
||||||
const supportsTools = TOOL_CALLING_PROVIDERS.has(getActiveProviderName());
|
const supportsTools = TOOL_CALLING_PROVIDERS.has(getActiveProviderName());
|
||||||
const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot';
|
const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot';
|
||||||
const systemPrompt = makeGeneralAgentPrompt(contextData, includeToolsInPrompt);
|
const uiContext = state.chatContext.uiContext;
|
||||||
|
const systemPrompt = makeGeneralAgentPrompt(contextData, includeToolsInPrompt, uiContext);
|
||||||
|
|
||||||
console.log(`[Orchestrator] generalAgent: provider="${getActiveProviderName()}", supportsTools=${supportsTools}`);
|
console.log(`[Orchestrator] generalAgent: provider="${getActiveProviderName()}", supportsTools=${supportsTools}`);
|
||||||
|
|
||||||
|
|||||||
@@ -552,6 +552,7 @@ const aiRouter = router({
|
|||||||
context: z.object({
|
context: z.object({
|
||||||
type: z.enum(['global', 'project']),
|
type: z.enum(['global', 'project']),
|
||||||
projectId: z.string().optional(),
|
projectId: z.string().optional(),
|
||||||
|
uiContext: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import ReactMarkdown from 'react-markdown';
|
|||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
|
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
@@ -19,19 +18,11 @@ const SUGGESTION_CHIPS = [
|
|||||||
|
|
||||||
interface AIChatPanelProps {
|
interface AIChatPanelProps {
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
contextType: 'global' | 'project';
|
|
||||||
projectId?: string;
|
|
||||||
projectName?: string;
|
|
||||||
curtainOpen: boolean;
|
|
||||||
isHomePage?: boolean;
|
isHomePage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AIChatPanel({
|
export function AIChatPanel({
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
contextType,
|
|
||||||
projectId,
|
|
||||||
projectName,
|
|
||||||
curtainOpen,
|
|
||||||
isHomePage,
|
isHomePage,
|
||||||
}: AIChatPanelProps) {
|
}: AIChatPanelProps) {
|
||||||
const hasTokenQuery = trpc.ai.hasToken.useQuery();
|
const hasTokenQuery = trpc.ai.hasToken.useQuery();
|
||||||
@@ -41,11 +32,8 @@ export function AIChatPanel({
|
|||||||
const dueTodayQuery = trpc.tasks.dueToday.useQuery(undefined, { enabled: !!isHomePage });
|
const dueTodayQuery = trpc.tasks.dueToday.useQuery(undefined, { enabled: !!isHomePage });
|
||||||
|
|
||||||
const chatContext = useMemo<ChatContext>(
|
const chatContext = useMemo<ChatContext>(
|
||||||
() => ({
|
() => ({ type: 'global' as const }),
|
||||||
type: contextType,
|
[],
|
||||||
...(contextType === 'project' && projectId ? { projectId } : {}),
|
|
||||||
}),
|
|
||||||
[contextType, projectId],
|
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
@@ -71,15 +59,6 @@ export function AIChatPanel({
|
|||||||
if (el) el.scrollTo({ top: el.scrollHeight });
|
if (el) el.scrollTo({ top: el.scrollHeight });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Reset input when curtain closes; scroll to bottom when it reopens
|
|
||||||
useEffect(() => {
|
|
||||||
if (!curtainOpen) {
|
|
||||||
setInput('');
|
|
||||||
} else {
|
|
||||||
setTimeout(scrollToBottom, 50);
|
|
||||||
}
|
|
||||||
}, [curtainOpen, scrollToBottom]);
|
|
||||||
|
|
||||||
// Auto-scroll when messages change or streaming content updates
|
// Auto-scroll when messages change or streaming content updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
@@ -130,60 +109,15 @@ export function AIChatPanel({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Smart wheel handler: only stop propagation when there's content to scroll through
|
|
||||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
|
||||||
const el = messagesContainerRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 2;
|
|
||||||
const atTop = el.scrollTop < 2;
|
|
||||||
// Let event propagate to AppShell when at boundaries
|
|
||||||
if ((e.deltaY > 0 && atBottom) || (e.deltaY < 0 && atTop)) return;
|
|
||||||
e.stopPropagation();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// No token configured — show settings prompt
|
|
||||||
if (hasTokenQuery.data === false && !isHomePage) {
|
|
||||||
return (
|
|
||||||
<div className="absolute inset-0 z-0 flex flex-col items-center justify-center bg-background">
|
|
||||||
<Card className="max-w-sm">
|
|
||||||
<CardContent className="flex flex-col items-center gap-4 pt-6">
|
|
||||||
<KeyRound size={32} className="text-muted-foreground" />
|
|
||||||
<div className="text-center space-y-1">
|
|
||||||
<p className="text-sm font-medium">AI provider not configured</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Connect your GitHub Copilot token to enable AI-powered features
|
|
||||||
like chat, summaries, and suggestions.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm" onClick={onOpenSettings}>
|
|
||||||
Open Settings
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasMessages = messages.length > 0 || isStreaming;
|
const hasMessages = messages.length > 0 || isStreaming;
|
||||||
|
|
||||||
const contextLabel =
|
|
||||||
contextType === 'project' && projectName
|
|
||||||
? `Chatting about: ${projectName}`
|
|
||||||
: 'Global workspace';
|
|
||||||
|
|
||||||
// Derived values for home page
|
// Derived values for home page
|
||||||
const dueCount = dueTodayQuery.data?.length ?? 0;
|
const dueCount = dueTodayQuery.data?.length ?? 0;
|
||||||
const userName = userNameQuery.data ?? 'there';
|
const userName = userNameQuery.data ?? 'there';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-0 flex flex-col bg-background">
|
<div className="absolute inset-0 z-0 flex flex-col bg-background">
|
||||||
{/* Context header (non-home) */}
|
|
||||||
{!isHomePage && (
|
|
||||||
<div className="flex items-center gap-2 px-6 pt-4 pb-2">
|
|
||||||
<Badge variant="outline">{contextLabel}</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Scrollable messages area */}
|
{/* Scrollable messages area */}
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
className="flex-1 min-h-0"
|
className="flex-1 min-h-0"
|
||||||
@@ -193,7 +127,6 @@ export function AIChatPanel({
|
|||||||
? '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-center'
|
? '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-center'
|
||||||
: '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-end'
|
: '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-end'
|
||||||
}
|
}
|
||||||
onWheel={handleWheel}
|
|
||||||
>
|
>
|
||||||
{/* Home page initial state: greeting + brief */}
|
{/* Home page initial state: greeting + brief */}
|
||||||
{isHomePage && !hasMessages && (
|
{isHomePage && !hasMessages && (
|
||||||
@@ -246,7 +179,6 @@ export function AIChatPanel({
|
|||||||
onInputChange={setInput}
|
onInputChange={setInput}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
isHomePage={isHomePage}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-wrap items-center justify-center gap-2 mt-4">
|
<div className="flex flex-wrap items-center justify-center gap-2 mt-4">
|
||||||
{SUGGESTION_CHIPS.map((chip) => (
|
{SUGGESTION_CHIPS.map((chip) => (
|
||||||
@@ -336,79 +268,19 @@ export function AIChatPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Non-home messages */}
|
{/* Non-home messages */}
|
||||||
{!isHomePage && hasMessages && (
|
|
||||||
<div className="mx-auto w-full max-w-[1088px] px-6 pt-4 pb-32">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{messages.map((msg) => {
|
|
||||||
if (msg.role === 'user') {
|
|
||||||
return (
|
|
||||||
<div key={msg.id} className="flex justify-end">
|
|
||||||
<div className="ml-auto max-w-[75%] rounded-2xl bg-muted px-4 py-2">
|
|
||||||
<ChatMarkdown content={msg.content} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.error) {
|
|
||||||
return (
|
|
||||||
<div key={msg.id} className="mr-auto max-w-[75%]">
|
|
||||||
<p className="text-sm text-destructive whitespace-pre-wrap">
|
|
||||||
{msg.content}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={msg.id} className="mr-auto max-w-[75%]">
|
|
||||||
<div className="flex items-center gap-1.5 mb-1">
|
|
||||||
<Sparkles size={16} className="text-foreground" />
|
|
||||||
<span className="text-sm font-semibold">Adiuva</span>
|
|
||||||
</div>
|
|
||||||
<div className="pl-[22px]">
|
|
||||||
<ChatMarkdown content={msg.content} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Streaming AI response */}
|
|
||||||
{isStreaming && (
|
|
||||||
<div className="mr-auto max-w-[75%]">
|
|
||||||
<div className="flex items-center gap-1.5 mb-1">
|
|
||||||
<Sparkles size={16} className="text-foreground" />
|
|
||||||
<span className="text-sm font-semibold">Adiuva</span>
|
|
||||||
</div>
|
|
||||||
{streamingContent ? (
|
|
||||||
<div className="pl-[22px]">
|
|
||||||
<ChatMarkdown content={streamingContent} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2 pl-[22px]">
|
|
||||||
<Skeleton className="h-4 w-48" />
|
|
||||||
<Skeleton className="h-4 w-32" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{/* Fixed input — pinned to the bottom (hidden on home initial state) */}
|
{/* Fixed input — pinned to the bottom (hidden on initial state) */}
|
||||||
{!(isHomePage && !hasMessages) && (
|
{hasMessages && (
|
||||||
<div className="absolute bottom-0 left-0 right-0 z-10 px-6 pb-5 pt-16 pointer-events-none">
|
<div className="absolute bottom-0 left-0 right-0 z-10 px-6 pb-5 pt-16 pointer-events-none">
|
||||||
<div className="absolute inset-x-0 top-0 h-full bg-gradient-to-b from-transparent via-background/60 to-background/90" />
|
<div className="absolute inset-x-0 top-0 h-full bg-gradient-to-b from-transparent via-background/60 to-background/90" />
|
||||||
<div className={`relative pointer-events-auto mx-auto ${isHomePage ? 'max-w-3xl' : 'max-w-[1088px]'}`}>
|
<div className="relative pointer-events-auto mx-auto max-w-3xl">
|
||||||
<ChatInput
|
<ChatInput
|
||||||
input={input}
|
input={input}
|
||||||
isStreaming={isStreaming || briefLoading}
|
isStreaming={isStreaming || briefLoading}
|
||||||
onInputChange={setInput}
|
onInputChange={setInput}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
isHomePage={isHomePage}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -425,7 +297,6 @@ interface ChatInputProps {
|
|||||||
onInputChange: (value: string) => void;
|
onInputChange: (value: string) => void;
|
||||||
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||||
onSend: () => void;
|
onSend: () => void;
|
||||||
isHomePage?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatInput({
|
function ChatInput({
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import {
|
|||||||
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
|
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
|
||||||
import { ChatMarkdown } from '@/components/ai/AIChatPanel';
|
import { ChatMarkdown } from '@/components/ai/AIChatPanel';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { trpc } from '@/lib/trpc';
|
||||||
|
|
||||||
function FloatingChatInner() {
|
function FloatingChatInner() {
|
||||||
const { state, sections, close } = useFloatingChat();
|
const { state, sections, close, setMorphTarget } = useFloatingChat();
|
||||||
|
const utils = trpc.useUtils();
|
||||||
const routerState = useRouterState();
|
const routerState = useRouterState();
|
||||||
const prevPathRef = useRef(routerState.location.pathname);
|
const prevPathRef = useRef(routerState.location.pathname);
|
||||||
|
|
||||||
@@ -77,6 +79,31 @@ function FloatingChatInner() {
|
|||||||
prevOpenRef.current = state.isOpen;
|
prevOpenRef.current = state.isOpen;
|
||||||
}, [state.isOpen, clearMessages]);
|
}, [state.isOpen, clearMessages]);
|
||||||
|
|
||||||
|
// ---- AI action: morph into newly-created task ----
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state.isOpen) return;
|
||||||
|
|
||||||
|
const unsubscribe = window.electronAI.onAction((action) => {
|
||||||
|
if (action.type === 'task_created' && action.taskId) {
|
||||||
|
// Invalidate task queries so the new TaskRow renders
|
||||||
|
void utils.tasks.list.invalidate();
|
||||||
|
|
||||||
|
// Set the morph target layoutId
|
||||||
|
setMorphTarget(`task-morph-${action.taskId}`);
|
||||||
|
|
||||||
|
// Wait for the TaskRow to render, then close (triggering FLIP)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, [state.isOpen, utils, setMorphTarget, close]);
|
||||||
|
|
||||||
// ---- Window resize: keep within bounds ----
|
// ---- Window resize: keep within bounds ----
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useRouterState } from '@tanstack/react-router';
|
import { Link, useRouterState } from '@tanstack/react-router';
|
||||||
import { motion, useMotionValue, useSpring } from 'framer-motion';
|
import { LayoutGroup } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
House,
|
House,
|
||||||
ChartGantt,
|
ChartGantt,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
ChevronUp,
|
|
||||||
ChevronDown,
|
|
||||||
Settings,
|
Settings,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Check,
|
Check,
|
||||||
@@ -71,20 +69,6 @@ interface AppShellProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Walk up the DOM to find the nearest scrollable ancestor. */
|
|
||||||
function findScrollableAncestor(el: Element | null): Element | null {
|
|
||||||
if (!el || el === document.body) return null;
|
|
||||||
const style = window.getComputedStyle(el);
|
|
||||||
const overflowY = style.overflowY;
|
|
||||||
if (
|
|
||||||
(overflowY === 'auto' || overflowY === 'scroll') &&
|
|
||||||
el.scrollHeight > el.clientHeight
|
|
||||||
) {
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
return findScrollableAncestor(el.parentElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppShell({ children }: AppShellProps) {
|
export function AppShell({ children }: AppShellProps) {
|
||||||
return (
|
return (
|
||||||
<FloatingChatProvider>
|
<FloatingChatProvider>
|
||||||
@@ -132,138 +116,23 @@ function AppShellInner({ children }: AppShellProps) {
|
|||||||
|
|
||||||
const isHomePage = currentPath === '/';
|
const isHomePage = currentPath === '/';
|
||||||
|
|
||||||
// Curtain is disabled on home page and on /projects without a selected project
|
|
||||||
const searchObj = routerState.location.search as Record<string, unknown>;
|
|
||||||
const projectId = typeof searchObj['projectId'] === 'string' ? searchObj['projectId'] : undefined;
|
|
||||||
const curtainEnabled =
|
|
||||||
currentPath !== '/' &&
|
|
||||||
!(currentPath === '/projects' && !projectId);
|
|
||||||
const curtainEnabledRef = useRef(curtainEnabled);
|
|
||||||
curtainEnabledRef.current = curtainEnabled;
|
|
||||||
|
|
||||||
// Derive AI chat context from current route
|
|
||||||
const isProjectView = currentPath === '/projects' && !!projectId;
|
|
||||||
const contextType = isProjectView ? 'project' as const : 'global' as const;
|
|
||||||
const projectQuery = trpc.projects.get.useQuery(
|
|
||||||
{ id: projectId ?? '' },
|
|
||||||
{ enabled: !!projectId },
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Curtain animation state ---
|
|
||||||
const [curtainOpen, setCurtainOpen] = useState(false);
|
|
||||||
const curtainOpenRef = useRef(false);
|
|
||||||
|
|
||||||
const y = useMotionValue(0);
|
|
||||||
const springY = useSpring(y, { stiffness: 300, damping: 30 });
|
|
||||||
|
|
||||||
const openCurtain = useCallback(() => {
|
|
||||||
curtainOpenRef.current = true;
|
|
||||||
setCurtainOpen(true);
|
|
||||||
y.set(window.innerHeight);
|
|
||||||
}, [y]);
|
|
||||||
|
|
||||||
const closeCurtain = useCallback(() => {
|
|
||||||
curtainOpenRef.current = false;
|
|
||||||
setCurtainOpen(false);
|
|
||||||
y.set(0);
|
|
||||||
}, [y]);
|
|
||||||
|
|
||||||
const toggleCurtain = useCallback(() => {
|
|
||||||
if (curtainOpenRef.current) closeCurtain();
|
|
||||||
else openCurtain();
|
|
||||||
}, [openCurtain, closeCurtain]);
|
|
||||||
|
|
||||||
// Keep curtain position in sync with window height on resize
|
|
||||||
useEffect(() => {
|
|
||||||
const handleResize = () => {
|
|
||||||
if (curtainOpenRef.current) {
|
|
||||||
y.set(window.innerHeight);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
|
||||||
}, [y]);
|
|
||||||
|
|
||||||
// Keyboard shortcut: Cmd/Ctrl+K
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!curtainEnabledRef.current) return;
|
|
||||||
toggleCurtain();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [toggleCurtain]);
|
|
||||||
|
|
||||||
// Wheel event: overscroll detection
|
|
||||||
useEffect(() => {
|
|
||||||
const handleWheel = (e: WheelEvent) => {
|
|
||||||
if (!curtainOpenRef.current) {
|
|
||||||
if (!curtainEnabledRef.current) return;
|
|
||||||
// Opening: overscroll UP (deltaY < 0) when content is at top
|
|
||||||
if (e.deltaY < 0) {
|
|
||||||
const scrollable = findScrollableAncestor(e.target as Element);
|
|
||||||
const atTop = !scrollable || scrollable.scrollTop === 0;
|
|
||||||
if (atTop) openCurtain();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Closing: scroll DOWN (deltaY > 0) while curtain is open
|
|
||||||
if (e.deltaY > 0) {
|
|
||||||
closeCurtain();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('wheel', handleWheel, { passive: true });
|
|
||||||
return () => document.removeEventListener('wheel', handleWheel);
|
|
||||||
}, [openCurtain, closeCurtain]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<LayoutGroup>
|
||||||
<SidebarProvider open={open} onOpenChange={handleOpenChange}>
|
<SidebarProvider open={open} onOpenChange={handleOpenChange}>
|
||||||
<AppSidebar
|
<AppSidebar
|
||||||
currentPath={currentPath}
|
currentPath={currentPath}
|
||||||
setTokenDialogOpen={setTokenDialogOpen}
|
setTokenDialogOpen={setTokenDialogOpen}
|
||||||
onNavClick={closeCurtain}
|
|
||||||
/>
|
/>
|
||||||
<SidebarInset className="overflow-hidden">
|
<SidebarInset>
|
||||||
{/* AI Chat layer: always mounted behind the content panel */}
|
{isHomePage ? (
|
||||||
<AIChatPanel
|
<AIChatPanel
|
||||||
onOpenSettings={() => setTokenDialogOpen(true)}
|
onOpenSettings={() => setTokenDialogOpen(true)}
|
||||||
contextType={contextType}
|
isHomePage
|
||||||
projectId={projectId}
|
|
||||||
projectName={projectQuery.data?.name}
|
|
||||||
curtainOpen={isHomePage || curtainOpen}
|
|
||||||
isHomePage={isHomePage}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content panel: slides down to reveal chat (hidden on home — AIChatPanel IS the home page) */}
|
|
||||||
{!isHomePage && (
|
|
||||||
<motion.div
|
|
||||||
style={{ y: springY }}
|
|
||||||
className="absolute inset-0 z-10 flex flex-col bg-background"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
|
|
||||||
{/* Right-edge vertical affordance (non-interactive) */}
|
|
||||||
<div className={`absolute right-0 top-0 flex items-end justify-center pt-8 pointer-events-none select-none${!curtainEnabled ? ' hidden' : ''}`}>
|
|
||||||
<div className="flex flex-col items-center gap-1.5 pr-2">
|
|
||||||
{curtainOpen ? (
|
|
||||||
<ChevronDown size={10} />
|
|
||||||
) : (
|
) : (
|
||||||
<ChevronUp size={10} />
|
<div className="relative flex flex-col h-full">
|
||||||
)}
|
{children}
|
||||||
<span
|
|
||||||
className="text-[9px] tracking-widest uppercase font-medium"
|
|
||||||
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
|
|
||||||
>
|
|
||||||
{curtainOpen ? 'back to app' : 'scrolling up for Adiuva'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
)}
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
@@ -314,17 +183,16 @@ function AppShellInner({ children }: AppShellProps) {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</LayoutGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppSidebarProps {
|
interface AppSidebarProps {
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
setTokenDialogOpen: (open: boolean) => void;
|
setTokenDialogOpen: (open: boolean) => void;
|
||||||
onNavClick: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppSidebar({ currentPath, setTokenDialogOpen, onNavClick }: AppSidebarProps) {
|
function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
|
||||||
const { toggleSidebar } = useSidebar();
|
const { toggleSidebar } = useSidebar();
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
@@ -377,7 +245,7 @@ function AppSidebar({ currentPath, setTokenDialogOpen, onNavClick }: AppSidebarP
|
|||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
tooltip={label}
|
tooltip={label}
|
||||||
>
|
>
|
||||||
<Link to={to} onClick={onNavClick}>
|
<Link to={to}>
|
||||||
<Icon />
|
<Icon />
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useMemo, useCallback } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
import { DragDropContext, Droppable, Draggable, type DropResult } from '@hello-pangea/dnd';
|
import { DragDropContext, Droppable, Draggable, type DropResult } from '@hello-pangea/dnd';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
|
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
|
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
|
||||||
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
|
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
|
||||||
@@ -22,6 +23,7 @@ type KanbanBoardProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: KanbanBoardProps) {
|
export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: KanbanBoardProps) {
|
||||||
|
const { state: floatingState } = useFloatingChat();
|
||||||
const { data: tasksList } = trpc.tasks.list.useQuery({ projectId });
|
const { data: tasksList } = trpc.tasks.list.useQuery({ projectId });
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
@@ -125,6 +127,11 @@ export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: Kan
|
|||||||
onDelete={(id) => deleteTask.mutate({ id })}
|
onDelete={(id) => deleteTask.mutate({ id })}
|
||||||
onClick={setViewTask}
|
onClick={setViewTask}
|
||||||
hideBreadcrumb
|
hideBreadcrumb
|
||||||
|
layoutId={
|
||||||
|
floatingState.morphTargetId === `task-morph-${task.id}`
|
||||||
|
? floatingState.morphTargetId
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 pe-8 flex flex-col gap-6">
|
<div className="p-6 flex flex-col gap-6">
|
||||||
{/* Breadcrumb + Project Name */}
|
{/* Breadcrumb + Project Name */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{breadcrumbPath.length > 0 && (
|
{breadcrumbPath.length > 0 && (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
import { Calendar, User, Pencil, Trash2 } from 'lucide-react';
|
import { Calendar, User, Pencil, Trash2 } from 'lucide-react';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -57,6 +58,7 @@ export function TaskRow({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onClick,
|
onClick,
|
||||||
hideBreadcrumb,
|
hideBreadcrumb,
|
||||||
|
layoutId,
|
||||||
}: {
|
}: {
|
||||||
task: TaskItem;
|
task: TaskItem;
|
||||||
onToggle: (id: string, status: string | null) => void;
|
onToggle: (id: string, status: string | null) => void;
|
||||||
@@ -64,6 +66,7 @@ export function TaskRow({
|
|||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
onClick?: (task: TaskItem) => void;
|
onClick?: (task: TaskItem) => void;
|
||||||
hideBreadcrumb?: boolean;
|
hideBreadcrumb?: boolean;
|
||||||
|
layoutId?: string;
|
||||||
}) {
|
}) {
|
||||||
const isDone = task.status === 'done';
|
const isDone = task.status === 'done';
|
||||||
|
|
||||||
@@ -84,10 +87,14 @@ export function TaskRow({
|
|||||||
breadcrumb.length > 0 ||
|
breadcrumb.length > 0 ||
|
||||||
task.assignee;
|
task.assignee;
|
||||||
|
|
||||||
|
const Wrapper = layoutId ? motion.div : 'div';
|
||||||
|
const wrapperProps = layoutId ? { layoutId, layout: true as const } : {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div
|
<Wrapper
|
||||||
|
{...wrapperProps}
|
||||||
className={`flex flex-col gap-1.5 px-4 py-3 rounded-md border select-none transition-colors ${
|
className={`flex flex-col gap-1.5 px-4 py-3 rounded-md border select-none transition-colors ${
|
||||||
isDone ? 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900' : 'bg-card border-border'
|
isDone ? 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900' : 'bg-card border-border'
|
||||||
} ${onClick ? 'cursor-pointer hover:bg-accent/50' : 'cursor-default'}`}
|
} ${onClick ? 'cursor-pointer hover:bg-accent/50' : 'cursor-default'}`}
|
||||||
@@ -146,7 +153,7 @@ export function TaskRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Wrapper>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
|
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ function NoteDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 pe-8 flex-col">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const ORDER_LABELS: Record<OrderBy, string> = {
|
|||||||
function TasksPage() {
|
function TasksPage() {
|
||||||
// Temporary test: register section for floating AI chat
|
// Temporary test: register section for floating AI chat
|
||||||
const testRef = useRef<HTMLDivElement>(null);
|
const testRef = useRef<HTMLDivElement>(null);
|
||||||
const { registerSection, unregisterSection } = useFloatingChat();
|
const { state: floatingState, registerSection, unregisterSection } = useFloatingChat();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerSection({ id: 'test', label: 'Tasks', ref: testRef });
|
registerSection({ id: 'test', label: 'Tasks', ref: testRef });
|
||||||
return () => unregisterSection('test');
|
return () => unregisterSection('test');
|
||||||
@@ -119,7 +119,7 @@ function TasksPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 p-6 pe-8 w-full">
|
<div className="flex flex-col gap-6 p-6 w-full">
|
||||||
{/* Stat Cards */}
|
{/* Stat Cards */}
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div className="grid grid-cols-4 gap-4">
|
||||||
<Item variant="muted">
|
<Item variant="muted">
|
||||||
@@ -225,6 +225,11 @@ function TasksPage() {
|
|||||||
onEdit={setEditTask}
|
onEdit={setEditTask}
|
||||||
onDelete={(id) => deleteTask.mutate({ id })}
|
onDelete={(id) => deleteTask.mutate({ id })}
|
||||||
onClick={setViewTask}
|
onClick={setViewTask}
|
||||||
|
layoutId={
|
||||||
|
floatingState.morphTargetId === `task-morph-${task.id}`
|
||||||
|
? floatingState.morphTargetId
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ function TimelinePage() {
|
|||||||
}, [ganttCheckpoints]);
|
}, [ganttCheckpoints]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 p-6 pe-8 w-full">
|
<div className="flex flex-col gap-6 p-6 w-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-semibold">Timeline</h1>
|
<h1 className="text-xl font-semibold">Timeline</h1>
|
||||||
|
|||||||
Reference in New Issue
Block a user