24 Commits

Author SHA1 Message Date
e8c8ddd48d feat: add CI/CD pipeline with cross-compilation support 2026-03-04 09:00:36 +01:00
Roberto Musso
d82738e7ea feat(AppShell): add SidebarTrigger component for improved sidebar accessibility 2026-03-01 14:37:08 +01:00
Roberto Musso
e005872ba0 feat(AIChatPanel): add aria-labels for accessibility; clean up unused lines
feat(AppShell): improve token storage message styling for better visibility
feat(ProjectDetail): implement skeleton loading state for project details
fix(ProjectSidebar): refactor variable declaration for clarity
style(PriorityBadge): enhance priority badge colors for better contrast
refactor(TaskRow): simplify className handling with utility function
fix(TasksPage): replace loader icon with clock icon for in-progress tasks
feat(TimelinePage): enhance empty state with descriptive messaging and icon
2026-03-01 10:40:22 +01:00
Roberto Musso
d3e82a3ebb feat(AIChatPanel): implement dynamic chat message font size and enhance user message scrolling behavior 2026-03-01 00:21:57 +01:00
Roberto Musso
af8cbc1c96 fix: update default userName from 'Roberto' to 'there' in store and AIChatPanel 2026-02-28 23:53:25 +01:00
Roberto Musso
ee6467a7ac feat: add knip configuration file and integrate knip for linting; update package.json and package-lock.json for new dependencies; refactor various interfaces to remove export modifiers; delete unused hover-card component 2026-02-28 23:44:10 +01:00
Roberto Musso
cdf9a8bf18 feat(FloatingChat): refactor chat width handling to be dynamic; enhance message panel positioning and styling with glass surface effects 2026-02-28 23:30:47 +01:00
Roberto Musso
f767bb5175 feat(AIChatPanel): update GradualBlur component to enhance blur effect; adjust ScrollArea scrollbar class handling 2026-02-28 22:47:09 +01:00
Roberto Musso
444aa37be2 feat(AIChatPanel): enhance daily brief with animation and expand/collapse functionality; add GradualBlur component for improved UI 2026-02-28 16:19:15 +01:00
Roberto Musso
15051cfa7a feat(floating-ai): step 8 — page interactions (all variants)
Register AI sections across all content pages with dual-anchor scroll
tracking, cross-page navigation via [SECTION:xxx] tags, and right-margin
positioning for the notes editor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:15:27 +01:00
Roberto Musso
c5e78311e6 feat: add CLAUDE.md for development guidance and update .gitignore to include .claude directory; refactor AIChatPanel and AppShell components for improved context handling; simplify layout in ProjectDetail, NoteDetailPage, TasksPage, and TimelinePage components 2026-02-28 13:42:52 +01:00
Roberto Musso
60b76c6d97 feat(floating-ai): step 7 — implement morph animation (FLIP)
Add FLIP animation so the floating chat visually morphs into a newly-created
TaskRow when the AI creates a task. Uses Framer Motion's shared layoutId
across FloatingChat and TaskRow, with LayoutGroup wrapping the app shell.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:27:23 +01:00
Roberto Musso
d12681b79f feat(floating-ai): step 6 — pass uiContext through to the AI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 09:40:00 +01:00
Roberto Musso
6c498c5f40 feat(floating-ai): step 5 — add ai:action IPC side-channel
Add a new ai:action IPC channel so the renderer can react to AI tool
side-effects (task creation, checkpoint/task suggestions). Also mark
AI-created tasks with isAiSuggested: 1 in both project and global
add_task tools.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 09:23:04 +01:00
Roberto Musso
310370ef66 fix(floating-ai): replace ScrollArea with div for message container in FloatingChat 2026-02-28 07:59:24 +01:00
Roberto Musso
f4e6238176 fix(tasks): adjust Floating AI chat section registration and styling 2026-02-28 00:16:54 +01:00
Roberto Musso
d8cf7814ab Merge branch 'feature/color' into feature/popup-ai 2026-02-27 23:56:19 +01:00
Roberto Musso
50b69aadbf feat(tasks): register section for floating AI chat in TasksPage 2026-02-27 23:56:08 +01:00
Roberto Musso
6cd121fa80 feat(floating-ai): step 4 — build FloatingChat component
Create the floating AI chat popup rendered via portal to document.body.
Uses useAIChat for chat logic, useFloatingChat for position/state,
Framer Motion for enter/exit animations, and pointer-event dragging.

Includes: close on Escape, close on route change, auto-scroll,
auto-focus, window resize clamping, and compact message rendering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 23:05:25 +01:00
Roberto Musso
28a5d65f1a feat(floating-ai): step 3 — create double-click hook
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:49:16 +01:00
Roberto Musso
b4e97e14f3 feat(floating-ai): step 2 — create section registry + FloatingChatContext
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:43:05 +01:00
Roberto Musso
78b4df1028 chore: remove unused Claude configuration files and update .gitignore 2026-02-27 22:35:18 +01:00
Roberto Musso
96101e4310 feat(floating-ai): step 1 — extract shared useAIChat hook
Refactor AIChatPanel to consume the existing useAIChat hook instead of
managing chat state inline. Removes duplicate ChatMessage interface,
inline state (messages, input, isStreaming, streamingContent), and the
65-line handleSend callback, replacing them with a single useAIChat()
call and a thin briefLoading guard wrapper.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:27:11 +01:00
Roberto Musso
9c07d3195f first color revision 2026-02-27 00:15:45 +01:00
36 changed files with 2440 additions and 766 deletions

View File

@@ -82,12 +82,9 @@ Tool-calling strategy differs by provider: OpenAI/Anthropic use LangChain `bindT
**Provider factory** (`llm.ts`): `gpt-4o-mini` (OpenAI), `claude-sonnet-4-20250514` (Anthropic), or ChatCopilot wrapper — all with `temperature: 0.3` and streaming enabled. **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: **Token storage** (`token.ts`) — two-tier fallback:
1. keytar (OS keychain) — preferred, encrypted per-user 1. electron-store + `safeStorage` — encrypted at rest (preferred)
2. electron-store + `safeStorage` — encrypted at rest 2. Plain electron-store — last resort (e.g. WSL with no keyring)
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. **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.
@@ -107,4 +104,33 @@ LanceDB stored in `{userData}/vectors/`. Table schema: `{ id, projectId, content
- Icons: **lucide-react** throughout — do not introduce other icon libraries - 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` - 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`) - 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) - 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.

View File

@@ -1,6 +0,0 @@
{
"enabledMcpjsonServers": [
"shadcn"
],
"enableAllProjectMcpServers": true
}

124
.gitea/workflows/build.yaml Normal file
View File

@@ -0,0 +1,124 @@
name: Release Electron App
run-name: Releasing ${{ gitea.ref_name }}
on:
push:
tags:
- 'v*'
jobs:
release-desktop:
runs-on: ubuntu-latest
container:
image: electronuserland/builder:wine
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Install System Dependencies for Linux Makers
run: |
apt-get update
apt-get install -y fakeroot dpkg mono-complete
- name: Install Dependencies
run: npm install
- name: Set version from tag
run: npm version "${GITHUB_REF_NAME#v}" --no-git-tag-version --allow-same-version
- name: Make App (Linux)
run: npm run make -- --platform=linux --arch=x64
- name: Initialize Wine
run: |
export WINEDEBUG=-all
export DISPLAY=
wineboot --init
env:
WINEDEBUG: '-all'
- name: Make App (Windows)
run: npm run make -- --platform=win32 --arch=x64
env:
WINEDEBUG: '-all'
- name: Create Gitea Release
run: |
GITEA_URL="http://10.0.0.119:3000"
TAG="${GITHUB_REF_NAME}"
REPO="${GITHUB_REPOSITORY}"
TOKEN="${{ gitea.token }}"
# Check if release exists, create if not
RELEASE_ID=$(curl -sf \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
if [ -z "$RELEASE_ID" ]; then
RELEASE_ID=$(curl -sf \
-X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
fi
echo "Release ID: ${RELEASE_ID}"
echo "RELEASE_ID=${RELEASE_ID}" >> "$GITHUB_ENV"
- name: Upload Release Assets
shell: bash
run: |
GITEA_URL="http://10.0.0.119:3000"
REPO="${GITHUB_REPOSITORY}"
TOKEN="${{ gitea.token }}"
MAX_RETRIES=3
upload_file() {
local file="$1"
local name
name=$(basename "$file")
local encoded_name
encoded_name=$(printf '%s' "$name" | sed 's/ /%20/g')
local attempt=1
while [ $attempt -le $MAX_RETRIES ]; do
local filesize
filesize=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null || echo "unknown")
echo "Uploading ${name} (${filesize} bytes, attempt ${attempt}/${MAX_RETRIES})..."
RESPONSE=$(curl -s -w "\n%{http_code}" \
--max-time 300 \
-X POST \
-H "Authorization: token ${TOKEN}" \
-H "Expect:" \
-F "attachment=@${file}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | head -n -1)
if [ "$HTTP_CODE" -ge 200 ] 2>/dev/null && [ "$HTTP_CODE" -lt 300 ] 2>/dev/null; then
echo "✅ Uploaded ${name}"
return 0
fi
echo "⚠️ Upload failed (HTTP ${HTTP_CODE}), body: ${BODY}"
echo "Retrying in 5s..."
sleep 5
attempt=$((attempt + 1))
done
echo "❌ Failed to upload ${name} after ${MAX_RETRIES} attempts"
return 1
}
FAILED=0
while IFS= read -r -d '' file; do
upload_file "$file" || FAILED=1
done < <(find out/make -type f \( -name "*.exe" -o -name "*.zip" -o -name "*.deb" -o -name "*.rpm" \) -print0)
if [ $FAILED -eq 1 ]; then
echo "Some uploads failed"
exit 1
fi

2
.gitignore vendored
View File

@@ -92,5 +92,5 @@ typings/
out/ out/
# local config files # local config files
.claude/
.vscode/ .vscode/

11
.vscode/mcp.json vendored
View File

@@ -1,11 +0,0 @@
{
"servers": {
"shadcn": {
"command": "npx",
"args": [
"shadcn@latest",
"mcp"
]
}
}
}

View File

@@ -8,7 +8,7 @@ This document is designed to be consumed **one step at a time across multiple Cl
### Workflow Protocol for Each Step ### Workflow Protocol for Each Step
1. **Start a new chat** and say: _"Read `docs/floating-ai-integration-guide.md` and implement Step X"_ 1. **Start a new chat** and say: _"Implement Step [X] from `docs/floating-ai-integration-guide.md`. Use a subagent to extract the general rules and only the data relevant to Step [X], completely ignoring the other steps."_
2. **Before writing any code**, the agent MUST: 2. **Before writing any code**, the agent MUST:
- Read ALL files listed in the step's "Files to Read First" section - Read ALL files listed in the step's "Files to Read First" section
- Read the project's `CLAUDE.md` for build/lint commands and conventions - Read the project's `CLAUDE.md` for build/lint commands and conventions
@@ -31,13 +31,13 @@ Steps MUST be implemented in order. Each step lists its prerequisites.
| Step | Title | Status | | Step | Title | Status |
|------|-------|--------| |------|-------|--------|
| 1 | Extract shared `useAIChat` hook | [ ] | | 1 | Extract shared `useAIChat` hook | [x] 2026-02-27 |
| 2 | Create section registry + `FloatingChatContext` | [ ] | | 2 | Create section registry + `FloatingChatContext` | [x] 2026-02-27 |
| 3 | Create double-click hook | [ ] | | 3 | Create double-click hook | [x] 2026-02-27 |
| 4 | Build `FloatingChat` component | [ ] | | 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 | [ ] |
@@ -56,7 +56,7 @@ A partial implementation of Step 1 already exists:
## Step 1: Extract Shared `useAIChat` Hook ## Step 1: Extract Shared `useAIChat` Hook
**Status**: [ ] **Status**: [x] 2026-02-27
**Prerequisites**: None **Prerequisites**: None
**Creates**: Nothing new (hook file already exists at `src/renderer/hooks/useAIChat.ts`) **Creates**: Nothing new (hook file already exists at `src/renderer/hooks/useAIChat.ts`)
**Modifies**: `src/renderer/components/ai/AIChatPanel.tsx` **Modifies**: `src/renderer/components/ai/AIChatPanel.tsx`
@@ -142,7 +142,7 @@ Refactor `AIChatPanel.tsx` to consume `useAIChat` instead of managing chat state
## Step 2: Create Section Registry + `FloatingChatContext` ## Step 2: Create Section Registry + `FloatingChatContext`
**Status**: [ ] **Status**: [x] 2026-02-27
**Prerequisites**: Step 1 completed **Prerequisites**: Step 1 completed
**Creates**: `src/renderer/context/FloatingChatContext.tsx` **Creates**: `src/renderer/context/FloatingChatContext.tsx`
**Modifies**: `src/renderer/components/layout/AppShell.tsx` **Modifies**: `src/renderer/components/layout/AppShell.tsx`
@@ -355,7 +355,7 @@ return (
## Step 3: Create Double-Click Hook ## Step 3: Create Double-Click Hook
**Status**: [ ] **Status**: [x] 2026-02-27
**Prerequisites**: Step 2 completed **Prerequisites**: Step 2 completed
**Creates**: `src/renderer/hooks/useDoubleClickAI.ts` **Creates**: `src/renderer/hooks/useDoubleClickAI.ts`
**Modifies**: `src/renderer/components/layout/AppShell.tsx` (add hook call) **Modifies**: `src/renderer/components/layout/AppShell.tsx` (add hook call)
@@ -452,7 +452,7 @@ function AppShellInner({ children }: AppShellProps) {
## Step 4: Build `FloatingChat` Component ## Step 4: Build `FloatingChat` Component
**Status**: [ ] **Status**: [x] 2026-02-27
**Prerequisites**: Steps 1-3 completed **Prerequisites**: Steps 1-3 completed
**Creates**: `src/renderer/components/ai/FloatingChat.tsx` **Creates**: `src/renderer/components/ai/FloatingChat.tsx`
**Modifies**: `src/renderer/components/layout/AppShell.tsx` (render the portal) **Modifies**: `src/renderer/components/layout/AppShell.tsx` (render the portal)
@@ -642,7 +642,7 @@ Use these existing patterns from the codebase:
## Step 5: Add `ai:action` IPC Side-Channel ## Step 5: Add `ai:action` IPC Side-Channel
**Status**: [ ] **Status**: [x] (2026-02-28)
**Prerequisites**: Step 4 completed **Prerequisites**: Step 4 completed
**Modifies**: **Modifies**:
- `src/preload/trpc.ts` - `src/preload/trpc.ts`
@@ -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)
@@ -959,7 +959,7 @@ const { state: floatingState } = useFloatingChat();
## Step 8a: Page Interactions — Project Detail ## Step 8a: Page Interactions — Project Detail
**Status**: [ ] **Status**: [x] (2026-02-28)
**Prerequisites**: Steps 1-4 completed **Prerequisites**: Steps 1-4 completed
**Modifies**: `src/renderer/components/projects/ProjectDetail.tsx` **Modifies**: `src/renderer/components/projects/ProjectDetail.tsx`
@@ -1022,7 +1022,7 @@ Repeat for all 4 sections.
## Step 8b: Page Interactions — Tasks Page ## Step 8b: Page Interactions — Tasks Page
**Status**: [ ] **Status**: [x] (2026-02-28)
**Prerequisites**: Steps 1-4 completed **Prerequisites**: Steps 1-4 completed
**Modifies**: `src/renderer/routes/tasks.tsx` **Modifies**: `src/renderer/routes/tasks.tsx`
@@ -1055,7 +1055,7 @@ Same pattern as 8a — create refs, add `data-ai-section` attributes, register i
## Step 8c: Page Interactions — Timeline Page ## Step 8c: Page Interactions — Timeline Page
**Status**: [ ] **Status**: [x] (2026-02-28)
**Prerequisites**: Steps 1-4 completed **Prerequisites**: Steps 1-4 completed
**Modifies**: `src/renderer/routes/timeline.tsx` **Modifies**: `src/renderer/routes/timeline.tsx`
@@ -1082,7 +1082,7 @@ Register 1 section:
## Step 8d: Page Interactions — Notes Page (Milkdown) ## Step 8d: Page Interactions — Notes Page (Milkdown)
**Status**: [ ] **Status**: [x] (2026-02-28)
**Prerequisites**: Steps 1-4 completed **Prerequisites**: Steps 1-4 completed
**Modifies**: `src/renderer/routes/notes.$noteId.tsx` **Modifies**: `src/renderer/routes/notes.$noteId.tsx`

View File

@@ -7,12 +7,232 @@ import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-nati
import { VitePlugin } from '@electron-forge/plugin-vite'; import { VitePlugin } from '@electron-forge/plugin-vite';
import { FusesPlugin } from '@electron-forge/plugin-fuses'; import { FusesPlugin } from '@electron-forge/plugin-fuses';
import { FuseV1Options, FuseVersion } from '@electron/fuses'; import { FuseV1Options, FuseVersion } from '@electron/fuses';
import path from 'node:path';
import fs from 'node:fs';
import { execSync } from 'node:child_process';
// Packages externalized in vite.main.config.mts that must be installed at runtime.
// Keep this list in sync with the Vite external array.
const externalPackages = [
'better-sqlite3',
'@github/copilot-sdk',
'@langchain/core',
'@langchain/langgraph',
'@langchain/openai',
'@langchain/anthropic',
'vectordb',
'electron-squirrel-startup',
'electron-store',
];
const config: ForgeConfig = { const config: ForgeConfig = {
packagerConfig: { packagerConfig: {
asar: true, asar: {
unpack: '**/{*.node,*.dll,*.so,*.dylib}',
},
name: 'adiuva',
}, },
rebuildConfig: {}, rebuildConfig: {},
hooks: {
packageAfterCopy: async (_forgeConfig, buildPath, _electronVersion, platform, arch) => {
// The VitePlugin's ignore filter only copies .vite/ into the build.
// Externalized packages need to be installed into node_modules here.
// At this point, only .vite/ exists. The VitePlugin writes package.json
// in its own afterCopy hook (which may run after ours). Read from source.
const srcPjPath = path.resolve(__dirname, 'package.json');
const pjPath = path.resolve(buildPath, 'package.json');
const pj = JSON.parse(fs.readFileSync(srcPjPath, 'utf-8'));
// Keep only externalized packages in dependencies
const filtered: Record<string, string> = {};
for (const pkg of externalPackages) {
if (pj.dependencies?.[pkg]) {
filtered[pkg] = pj.dependencies[pkg];
}
}
pj.dependencies = filtered;
delete pj.devDependencies;
fs.writeFileSync(pjPath, JSON.stringify(pj, null, 2));
// Copy lockfile for reproducible installs
const lockSrc = path.resolve(buildPath, '..', '..', 'package-lock.json');
if (fs.existsSync(lockSrc)) {
fs.copyFileSync(lockSrc, path.resolve(buildPath, 'package-lock.json'));
}
// Install only the externalized runtime deps
console.log('[forge] Installing externalized dependencies...');
execSync('npm install --omit=dev', {
cwd: buildPath,
stdio: 'inherit',
env: { ...process.env, npm_config_nodedir: '' },
});
const targetKey = `${platform}-${arch}`;
// vectordb uses platform-specific optional deps (@lancedb/vectordb-<platform>-<arch>-*).
// npm install on Linux only pulls the Linux variant. Force-install the target's.
const platformNativePackages: Record<string, Record<string, string>> = {
'win32-x64': {
'@lancedb/vectordb-win32-x64-msvc': '',
},
'linux-x64': {
'@lancedb/vectordb-linux-x64-gnu': '',
},
'darwin-x64': {
'@lancedb/vectordb-darwin-x64': '',
},
'darwin-arm64': {
'@lancedb/vectordb-darwin-arm64': '',
},
};
const nativePkgs = platformNativePackages[targetKey];
if (nativePkgs) {
// Remove wrong-platform lancedb native packages
const nmPath = path.join(buildPath, 'node_modules', '@lancedb');
if (fs.existsSync(nmPath)) {
for (const entry of fs.readdirSync(nmPath)) {
if (entry.startsWith('vectordb-') && !Object.keys(nativePkgs).includes(`@lancedb/${entry}`)) {
fs.rmSync(path.join(nmPath, entry), { recursive: true, force: true });
console.log(`[forge] Removed non-target native package: @lancedb/${entry}`);
}
}
}
// Install correct platform packages
const pkgsToInstall = Object.keys(nativePkgs).join(' ');
console.log(`[forge] Installing platform-specific packages for ${targetKey}: ${pkgsToInstall}`);
execSync(`npm install ${pkgsToInstall} --omit=dev --no-save --force`, {
cwd: buildPath,
stdio: 'inherit',
});
}
// Remove cross-platform prebuilt binaries that don't match the target.
// Packages like @github/copilot ship prebuilds for all platforms;
// keeping foreign-arch .node files breaks rpmbuild's strip step.
const nodeModulesPath = path.join(buildPath, 'node_modules');
const findPrebuilds = (dir: string): string[] => {
const results: string[] = [];
if (!fs.existsSync(dir)) return results;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'prebuilds') {
results.push(full);
} else {
results.push(...findPrebuilds(full));
}
}
}
return results;
};
for (const prebuildsDir of findPrebuilds(nodeModulesPath)) {
for (const entry of fs.readdirSync(prebuildsDir)) {
if (entry !== targetKey) {
const fullPath = path.join(prebuildsDir, entry);
fs.rmSync(fullPath, { recursive: true, force: true });
console.log(`[forge] Removed non-target prebuild: ${entry}`);
}
}
}
// @github/copilot ships @teddyzhu/clipboard-* platform packages outside
// of prebuilds/. Remove non-target variants to avoid bundling wrong binaries.
const clipboardDir = path.join(buildPath, 'node_modules', '@github', 'copilot', 'clipboard', 'node_modules', '@teddyzhu');
if (fs.existsSync(clipboardDir)) {
const targetClipboardMap: Record<string, string> = {
'win32-x64': 'clipboard-win32-x64-msvc',
'win32-arm64': 'clipboard-win32-arm64-msvc',
'linux-x64': 'clipboard-linux-x64-gnu',
'linux-arm64': 'clipboard-linux-arm64-gnu',
'darwin-x64': 'clipboard-darwin-x64',
'darwin-arm64': 'clipboard-darwin-arm64',
};
const wantedPkg = targetClipboardMap[targetKey];
for (const entry of fs.readdirSync(clipboardDir)) {
if (entry.startsWith('clipboard-') && entry !== wantedPkg) {
fs.rmSync(path.join(clipboardDir, entry), { recursive: true, force: true });
console.log(`[forge] Removed non-target clipboard package: @teddyzhu/${entry}`);
}
}
}
},
// ── Post-rebuild: fix native binaries for cross-compilation ──────
// Forge runs @electron/rebuild AFTER packageAfterCopy, which
// recompiles native addons for the BUILD platform (Linux).
// packageAfterPrune runs AFTER rebuild+prune, so we can safely
// replace the Linux .node files with the correct target prebuilts.
packageAfterPrune: async (_forgeConfig, buildPath, _electronVersion, platform, arch) => {
const targetKey = `${platform}-${arch}`;
const buildKey = `${process.platform}-${process.arch}`;
if (targetKey === buildKey) return; // native build — nothing to fix
console.log(`[forge:afterPrune] Cross-compile fixup: ${buildKey}${targetKey}`);
const electronVersion = JSON.parse(
fs.readFileSync(path.resolve(__dirname, 'node_modules', 'electron', 'package.json'), 'utf-8'),
).version;
// Replace native addons that @electron/rebuild compiled for the host.
const nativeModules = ['better-sqlite3'];
for (const mod of nativeModules) {
const modDir = path.join(buildPath, 'node_modules', mod);
if (!fs.existsSync(modDir)) continue;
// Remove the host-platform binary left by @electron/rebuild
const buildRelease = path.join(modDir, 'build', 'Release');
if (fs.existsSync(buildRelease)) {
fs.rmSync(buildRelease, { recursive: true, force: true });
console.log(`[forge:afterPrune] Cleaned host-platform build/Release for ${mod}`);
}
// Download the correct prebuilt for the TARGET platform
console.log(`[forge:afterPrune] Downloading ${mod} prebuilt for ${targetKey} (Electron ${electronVersion})...`);
execSync(
`npx --yes prebuild-install -r electron -t ${electronVersion} ` +
`--platform ${platform} --arch ${arch} --tag-prefix v --verbose`,
{ cwd: modDir, stdio: 'inherit' },
);
// Verify the binary exists and is for the correct platform.
const releaseDir = path.join(modDir, 'build', 'Release');
if (!fs.existsSync(releaseDir)) {
throw new Error(
`[forge] FATAL: build/Release/ not found for ${mod} after prebuild-install. ` +
`The native binary was not downloaded.`,
);
}
const nodeFiles = fs.readdirSync(releaseDir).filter((f) => f.endsWith('.node'));
if (nodeFiles.length === 0) {
throw new Error(
`[forge] FATAL: No .node files in build/Release/ for ${mod} after prebuild-install.`,
);
}
for (const f of nodeFiles) {
const buf = Buffer.alloc(4);
const fd = fs.openSync(path.join(releaseDir, f), 'r');
fs.readSync(fd, buf, 0, 4, 0);
fs.closeSync(fd);
// ELF magic: 0x7f 'E' 'L' 'F'
if (buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46) {
throw new Error(
`[forge] FATAL: ${mod} build/Release/${f} is an ELF binary! ` +
`Cross-compilation failed — refusing to package a Linux .node for Windows.`,
);
}
// PE magic: 'M' 'Z' (0x4d 0x5a) — expected for win32
if (platform === 'win32' && !(buf[0] === 0x4d && buf[1] === 0x5a)) {
throw new Error(
`[forge] FATAL: ${mod} build/Release/${f} is not a PE (Windows) binary! ` +
`Magic bytes: ${buf.toString('hex')}. Refusing to package.`,
);
}
console.log(`[forge:afterPrune] Verified ${mod}/${f} — correct platform ✓`);
}
}
},
},
makers: [ makers: [
new MakerSquirrel({}), new MakerSquirrel({}),
new MakerZIP({}, ['darwin']), new MakerZIP({}, ['darwin']),

22
knip.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"tags": ["-lintignore"],
"entry": [
"src/main/index.ts",
"src/preload/index.ts",
"src/preload/trpc.ts",
"forge.config.ts",
"vite.main.config.mts",
"vite.preload.config.mts",
"vite.renderer.config.mts"
],
"ignoreDependencies": [
"postcss",
"@electron-forge/shared-types",
"@milkdown/plugin-upload",
"@milkdown/prose"
],
"ignore": [
"src/renderer/components/ui/**"
]
}

476
package-lock.json generated
View File

@@ -17,7 +17,6 @@
"@langchain/langgraph": "^1.1.5", "@langchain/langgraph": "^1.1.5",
"@langchain/openai": "^1.2.9", "@langchain/openai": "^1.2.9",
"@milkdown/crepe": "^7.18.0", "@milkdown/crepe": "^7.18.0",
"@milkdown/kit": "^7.18.0",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.0", "@tailwindcss/vite": "^4.2.0",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
@@ -33,7 +32,6 @@
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
"electron-store": "^8.2.0", "electron-store": "^8.2.0",
"framer-motion": "^12.34.2", "framer-motion": "^12.34.2",
"keytar": "^7.9.0",
"lucide-react": "^0.575.0", "lucide-react": "^0.575.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.4", "react": "^19.2.4",
@@ -59,6 +57,7 @@
"@tanstack/router-vite-plugin": "^1.161.1", "@tanstack/router-vite-plugin": "^1.161.1",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/electron-squirrel-startup": "^1.0.2", "@types/electron-squirrel-startup": "^1.0.2",
"@types/node": "^25.3.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/eslint-plugin": "^5.62.0",
@@ -69,6 +68,7 @@
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-import-resolver-typescript": "^4.4.4", "eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
"knip": "^5.85.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"shadcn": "^3.8.5", "shadcn": "^3.8.5",
"tailwindcss": "^4.2.0", "tailwindcss": "^4.2.0",
@@ -4930,6 +4930,306 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@oxc-resolver/binding-android-arm-eabi": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.19.1.tgz",
"integrity": "sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@oxc-resolver/binding-android-arm64": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.19.1.tgz",
"integrity": "sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@oxc-resolver/binding-darwin-arm64": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.19.1.tgz",
"integrity": "sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@oxc-resolver/binding-darwin-x64": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.19.1.tgz",
"integrity": "sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@oxc-resolver/binding-freebsd-x64": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.19.1.tgz",
"integrity": "sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.19.1.tgz",
"integrity": "sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-arm-musleabihf": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.19.1.tgz",
"integrity": "sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-arm64-gnu": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.19.1.tgz",
"integrity": "sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-arm64-musl": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.19.1.tgz",
"integrity": "sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-ppc64-gnu": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.19.1.tgz",
"integrity": "sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-riscv64-gnu": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.19.1.tgz",
"integrity": "sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-riscv64-musl": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.19.1.tgz",
"integrity": "sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-s390x-gnu": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.19.1.tgz",
"integrity": "sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-x64-gnu": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.19.1.tgz",
"integrity": "sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-x64-musl": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.19.1.tgz",
"integrity": "sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-openharmony-arm64": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.19.1.tgz",
"integrity": "sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@oxc-resolver/binding-wasm32-wasi": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.19.1.tgz",
"integrity": "sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^1.1.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@oxc-resolver/binding-win32-arm64-msvc": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.19.1.tgz",
"integrity": "sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@oxc-resolver/binding-win32-ia32-msvc": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.19.1.tgz",
"integrity": "sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@oxc-resolver/binding-win32-x64-msvc": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.19.1.tgz",
"integrity": "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@radix-ui/number": { "node_modules/@radix-ui/number": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -7934,9 +8234,9 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "25.3.0", "version": "25.3.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
"integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -13488,6 +13788,16 @@
"reusify": "^1.0.4" "reusify": "^1.0.4"
} }
}, },
"node_modules/fd-package-json": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz",
"integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"walk-up-path": "^4.0.0"
}
},
"node_modules/fd-slicer": { "node_modules/fd-slicer": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
@@ -13758,6 +14068,22 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/formatly": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz",
"integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"fd-package-json": "^2.0.0"
},
"bin": {
"formatly": "bin/index.mjs"
},
"engines": {
"node": ">=18.3.0"
}
},
"node_modules/formdata-polyfill": { "node_modules/formdata-polyfill": {
"version": "4.0.10", "version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
@@ -15680,17 +16006,6 @@
"node": ">= 12" "node": ">= 12"
} }
}, },
"node_modules/keytar": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz",
"integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^4.3.0",
"prebuild-install": "^7.0.1"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -15711,6 +16026,74 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/knip": {
"version": "5.85.0",
"resolved": "https://registry.npmjs.org/knip/-/knip-5.85.0.tgz",
"integrity": "sha512-V2kyON+DZiYdNNdY6GALseiNCwX7dYdpz9Pv85AUn69Gk0UKCts+glOKWfe5KmaMByRjM9q17Mzj/KinTVOyxg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/webpro"
},
{
"type": "opencollective",
"url": "https://opencollective.com/knip"
}
],
"license": "ISC",
"dependencies": {
"@nodelib/fs.walk": "^1.2.3",
"fast-glob": "^3.3.3",
"formatly": "^0.3.0",
"jiti": "^2.6.0",
"js-yaml": "^4.1.1",
"minimist": "^1.2.8",
"oxc-resolver": "^11.15.0",
"picocolors": "^1.1.1",
"picomatch": "^4.0.1",
"smol-toml": "^1.5.2",
"strip-json-comments": "5.0.3",
"zod": "^4.1.11"
},
"bin": {
"knip": "bin/knip.js",
"knip-bun": "bin/knip-bun.js"
},
"engines": {
"node": ">=18.18.0"
},
"peerDependencies": {
"@types/node": ">=18",
"typescript": ">=5.0.4 <7"
}
},
"node_modules/knip/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/knip/node_modules/strip-json-comments": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
"integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/langsmith": { "node_modules/langsmith": {
"version": "0.5.6", "version": "0.5.6",
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.5.6.tgz", "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.5.6.tgz",
@@ -17814,12 +18197,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/node-addon-api": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz",
"integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==",
"license": "MIT"
},
"node_modules/node-api-version": { "node_modules/node-api-version": {
"version": "0.2.1", "version": "0.2.1",
"resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz",
@@ -18270,6 +18647,38 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/oxc-resolver": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.19.1.tgz",
"integrity": "sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
},
"optionalDependencies": {
"@oxc-resolver/binding-android-arm-eabi": "11.19.1",
"@oxc-resolver/binding-android-arm64": "11.19.1",
"@oxc-resolver/binding-darwin-arm64": "11.19.1",
"@oxc-resolver/binding-darwin-x64": "11.19.1",
"@oxc-resolver/binding-freebsd-x64": "11.19.1",
"@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1",
"@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1",
"@oxc-resolver/binding-linux-arm64-gnu": "11.19.1",
"@oxc-resolver/binding-linux-arm64-musl": "11.19.1",
"@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1",
"@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1",
"@oxc-resolver/binding-linux-riscv64-musl": "11.19.1",
"@oxc-resolver/binding-linux-s390x-gnu": "11.19.1",
"@oxc-resolver/binding-linux-x64-gnu": "11.19.1",
"@oxc-resolver/binding-linux-x64-musl": "11.19.1",
"@oxc-resolver/binding-openharmony-arm64": "11.19.1",
"@oxc-resolver/binding-wasm32-wasi": "11.19.1",
"@oxc-resolver/binding-win32-arm64-msvc": "11.19.1",
"@oxc-resolver/binding-win32-ia32-msvc": "11.19.1",
"@oxc-resolver/binding-win32-x64-msvc": "11.19.1"
}
},
"node_modules/p-cancelable": { "node_modules/p-cancelable": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
@@ -21397,6 +21806,19 @@
"npm": ">= 3.0.0" "npm": ">= 3.0.0"
} }
}, },
"node_modules/smol-toml": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz",
"integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 18"
},
"funding": {
"url": "https://github.com/sponsors/cyyynthia"
}
},
"node_modules/socks": { "node_modules/socks": {
"version": "2.8.7", "version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
@@ -23646,6 +24068,16 @@
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/walk-up-path": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz",
"integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==",
"dev": true,
"license": "ISC",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/watchpack": { "node_modules/watchpack": {
"version": "2.5.1", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",

View File

@@ -10,10 +10,11 @@
"package": "electron-forge package", "package": "electron-forge package",
"make": "electron-forge make", "make": "electron-forge make",
"publish": "electron-forge publish", "publish": "electron-forge publish",
"lint": "eslint --ext .ts,.tsx ." "lint": "eslint --ext .ts,.tsx .",
"knip": "knip"
}, },
"keywords": [], "keywords": [],
"author": "rmusso", "author": "roberto",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@electron-forge/cli": "^7.11.1", "@electron-forge/cli": "^7.11.1",
@@ -28,6 +29,7 @@
"@tanstack/router-vite-plugin": "^1.161.1", "@tanstack/router-vite-plugin": "^1.161.1",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/electron-squirrel-startup": "^1.0.2", "@types/electron-squirrel-startup": "^1.0.2",
"@types/node": "^25.3.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/eslint-plugin": "^5.62.0",
@@ -38,6 +40,7 @@
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-import-resolver-typescript": "^4.4.4", "eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
"knip": "^5.85.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"shadcn": "^3.8.5", "shadcn": "^3.8.5",
"tailwindcss": "^4.2.0", "tailwindcss": "^4.2.0",
@@ -53,7 +56,6 @@
"@langchain/langgraph": "^1.1.5", "@langchain/langgraph": "^1.1.5",
"@langchain/openai": "^1.2.9", "@langchain/openai": "^1.2.9",
"@milkdown/crepe": "^7.18.0", "@milkdown/crepe": "^7.18.0",
"@milkdown/kit": "^7.18.0",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.0", "@tailwindcss/vite": "^4.2.0",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
@@ -69,7 +71,6 @@
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
"electron-store": "^8.2.0", "electron-store": "^8.2.0",
"framer-motion": "^12.34.2", "framer-motion": "^12.34.2",
"keytar": "^7.9.0",
"lucide-react": "^0.575.0", "lucide-react": "^0.575.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.4", "react": "^19.2.4",

View File

@@ -24,18 +24,22 @@ import { searchNotes, type SearchResult } from '../db/vectordb';
const TOOL_CALLING_PROVIDERS = new Set(['openai', 'anthropic', 'copilot']); const TOOL_CALLING_PROVIDERS = new Set(['openai', 'anthropic', 'copilot']);
const AI_STREAM_CHANNEL = 'ai:stream'; const AI_STREAM_CHANNEL = 'ai:stream';
const AI_ACTION_CHANNEL = 'ai:action';
/** Module-level sender ref — set at the start of orchestrate() so tool closures can emit actions. */
let currentSender: Electron.WebContents | undefined;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface OrchestrateInput { 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;
} }
export interface OrchestrateResult { interface OrchestrateResult {
response: string; response: string;
error?: string; error?: string;
} }
@@ -185,9 +189,11 @@ function buildProjectTools(projectId: string): StructuredTool[] {
priority: input.priority ?? 'medium', priority: input.priority ?? 'medium',
dueDate: input.dueDate ? new Date(input.dueDate).getTime() : null, dueDate: input.dueDate ? new Date(input.dueDate).getTime() : null,
projectId, projectId,
isAiSuggested: 1,
createdAt: Date.now(), createdAt: Date.now(),
}) })
.run(); .run();
sendAction(currentSender, { type: 'task_created', taskId: id });
return `Task added: ${input.title}`; return `Task added: ${input.title}`;
}, },
{ {
@@ -262,6 +268,7 @@ function buildProjectTools(projectId: string): StructuredTool[] {
createdAt: Date.now(), createdAt: Date.now(),
}).run(); }).run();
} }
sendAction(currentSender, { type: 'checkpoints_suggested', count: parsed.length });
return jsonStr; return jsonStr;
} catch { } catch {
return '[]'; return '[]';
@@ -311,6 +318,7 @@ function buildProjectTools(projectId: string): StructuredTool[] {
createdAt: Date.now(), createdAt: Date.now(),
}).run(); }).run();
} }
sendAction(currentSender, { type: 'tasks_suggested', count: parsed.length });
return jsonStr; return jsonStr;
} catch { } catch {
return '[]'; return '[]';
@@ -348,9 +356,11 @@ function buildGlobalTools(): StructuredTool[] {
priority: input.priority ?? 'medium', priority: input.priority ?? 'medium',
dueDate: input.dueDate ? new Date(input.dueDate).getTime() : null, dueDate: input.dueDate ? new Date(input.dueDate).getTime() : null,
projectId: input.projectId ?? null, projectId: input.projectId ?? null,
isAiSuggested: 1,
createdAt: Date.now(), createdAt: Date.now(),
}) })
.run(); .run();
sendAction(currentSender, { type: 'task_created', taskId: id });
return `Task added: ${input.title}`; return `Task added: ${input.title}`;
}, },
{ {
@@ -435,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.
@@ -454,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.
@@ -471,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.
@@ -494,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.` : ''}`;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -505,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 */
@@ -573,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)');
@@ -661,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}`);
@@ -739,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}`);
@@ -849,12 +862,18 @@ function sendStreamChunk(sender: Electron.WebContents | undefined, token: string
sender.send(AI_STREAM_CHANNEL, { token, done }); sender.send(AI_STREAM_CHANNEL, { token, done });
} }
function sendAction(sender: Electron.WebContents | undefined, action: { type: string; taskId?: string; count?: number }): void {
if (!sender || sender.isDestroyed()) return;
sender.send(AI_ACTION_CHANNEL, action);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Orchestrate (public entry point) // Orchestrate (public entry point)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateResult> { export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateResult> {
const { message, context, sender } = input; const { message, context, sender } = input;
currentSender = sender;
// Quick check: is an LLM available? // Quick check: is an LLM available?
const llm = await getLLM(); const llm = await getLLM();

View File

@@ -33,7 +33,7 @@ export function getActiveProviderName(): string {
} }
/** Switch to a different registered provider. */ /** Switch to a different registered provider. */
export function setActiveProviderName(name: string): void { function setActiveProviderName(name: string): void {
const provider = providers.get(name); const provider = providers.get(name);
if (!provider) throw new Error(`Unknown AI provider: ${name}`); if (!provider) throw new Error(`Unknown AI provider: ${name}`);
activeProvider = provider; activeProvider = provider;

View File

@@ -2,27 +2,11 @@ import { safeStorage } from 'electron';
import { getStore } from '../store'; import { getStore } from '../store';
/** /**
* Token storage with three-tier fallback: * Token storage with two-tier fallback:
* 1. OS keychain via keytar (best — encrypted, per-user) * 1. Electron safeStorage + electron-store (encrypted at rest)
* 2. Electron safeStorage + electron-store (encrypted at rest) * 2. Plain electron-store (last resort — e.g. WSL with no keyring)
* 3. Plain electron-store (last resort — e.g. WSL with no keyring)
*/ */
let keytar: typeof import('keytar') | null = null;
let keytarFailed = false;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
keytar = require('keytar') as typeof import('keytar');
} catch {
keytarFailed = true;
console.log('[Token] keytar native module unavailable');
}
function useKeytar(): boolean {
return keytar !== null && !keytarFailed;
}
function canUseSafeStorage(): boolean { function canUseSafeStorage(): boolean {
try { try {
return safeStorage.isEncryptionAvailable(); return safeStorage.isEncryptionAvailable();
@@ -31,8 +15,6 @@ function canUseSafeStorage(): boolean {
} }
} }
const SERVICE_NAME = 'adiuva';
// --- electron-store helpers (with optional safeStorage encryption) --- // --- electron-store helpers (with optional safeStorage encryption) ---
function readFromStore(providerName: string): string | null { function readFromStore(providerName: string): string | null {
@@ -74,41 +56,16 @@ function removeFromStore(providerName: string): void {
/** Read a stored token for the given provider. */ /** Read a stored token for the given provider. */
export async function getToken(providerName: string): Promise<string | null> { export async function getToken(providerName: string): Promise<string | null> {
if (useKeytar()) {
try {
return await keytar!.getPassword(SERVICE_NAME, providerName);
} catch (err) {
console.log('[Token] keytar runtime error, falling back:', (err as Error).message);
keytarFailed = true;
}
}
return readFromStore(providerName); return readFromStore(providerName);
} }
/** Store a token for the given provider. */ /** Store a token for the given provider. */
export async function setToken(providerName: string, token: string): Promise<void> { export async function setToken(providerName: string, token: string): Promise<void> {
if (useKeytar()) {
try {
await keytar!.setPassword(SERVICE_NAME, providerName, token);
return;
} catch (err) {
console.log('[Token] keytar runtime error, falling back:', (err as Error).message);
keytarFailed = true;
}
}
writeToStore(providerName, token); writeToStore(providerName, token);
} }
/** Delete a stored token for the given provider. */ /** Delete a stored token for the given provider. */
export async function deleteToken(providerName: string): Promise<boolean> { async function deleteToken(providerName: string): Promise<boolean> {
if (useKeytar()) {
try {
return await keytar!.deletePassword(SERVICE_NAME, providerName);
} catch (err) {
console.log('[Token] keytar runtime error, falling back:', (err as Error).message);
keytarFailed = true;
}
}
removeFromStore(providerName); removeFromStore(providerName);
return true; return true;
} }

View File

@@ -14,7 +14,7 @@ import {
type AnyRouter, type AnyRouter,
} from '@trpc/server'; } from '@trpc/server';
export const IPC_CHANNEL = 'trpc'; const IPC_CHANNEL = 'trpc';
/** Context passed to every tRPC procedure via the IPC bridge. */ /** Context passed to every tRPC procedure via the IPC bridge. */
export type TRPCContext = { export type TRPCContext = {

View File

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

View File

@@ -20,6 +20,7 @@ contextBridge.exposeInMainWorld('electronTRPC', {
}); });
const AI_STREAM_CHANNEL = 'ai:stream'; const AI_STREAM_CHANNEL = 'ai:stream';
const AI_ACTION_CHANNEL = 'ai:action';
contextBridge.exposeInMainWorld('electronAI', { contextBridge.exposeInMainWorld('electronAI', {
/** Subscribe to AI streaming chunks. Returns an unsubscribe function. */ /** Subscribe to AI streaming chunks. Returns an unsubscribe function. */
@@ -30,4 +31,13 @@ contextBridge.exposeInMainWorld('electronAI', {
ipcRenderer.removeListener(AI_STREAM_CHANNEL, handler); ipcRenderer.removeListener(AI_STREAM_CHANNEL, handler);
}; };
}, },
/** Subscribe to AI action events (task created, suggestions, etc.). Returns unsubscribe. */
onAction: (cb: (data: { type: string; taskId?: string; count?: number }) => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: { type: string; taskId?: string; count?: number }) => cb(data);
ipcRenderer.on(AI_ACTION_CHANNEL, handler);
return () => {
ipcRenderer.removeListener(AI_ACTION_CHANNEL, handler);
};
},
}); });

View File

@@ -1,20 +1,17 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Sparkles, KeyRound, ArrowUp, ListTodo, TrendingUp, AlertCircle, Lightbulb } from 'lucide-react'; import { Sparkles, KeyRound, ArrowUp, ListTodo, TrendingUp, AlertCircle, Lightbulb, ChevronDown, ChevronUp, X } from 'lucide-react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { motion, AnimatePresence } from 'framer-motion';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
import { Card, CardContent } from '@/components/ui/card'; import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { GradualBlur } from '@/components/ui/gradual-blur';
interface ChatMessage { /** Fluid font size for chat messages — scales with viewport width */
id: string; const CHAT_FONT = 'clamp(1.125rem, 1.4vw, 1.375rem)';
role: 'user' | 'assistant';
content: string;
error?: boolean;
}
const SUGGESTION_CHIPS = [ const SUGGESTION_CHIPS = [
{ icon: ListTodo, label: "What's on my plate today?" }, { icon: ListTodo, label: "What's on my plate today?" },
@@ -23,21 +20,35 @@ const SUGGESTION_CHIPS = [
{ icon: Lightbulb, label: 'Suggest next actions' }, { icon: Lightbulb, label: 'Suggest next actions' },
] as const; ] as const;
function getTimeGreeting(): string {
const hour = new Date().getHours();
if (hour < 12) return 'Good morning,';
if (hour < 17) return 'Good afternoon,';
return 'Good evening,';
}
/* Entrance animation: staggered fade-up */
const stagger = {
hidden: {},
show: { transition: { staggerChildren: 0.08 } },
};
const fadeUp = {
hidden: { opacity: 0, y: 16 },
show: {
opacity: 1,
y: 0,
transition: { duration: 0.45, ease: [0.25, 0.1, 0.25, 1] as const },
},
};
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();
@@ -46,10 +57,18 @@ export function AIChatPanel({
const userNameQuery = trpc.settings.getUserName.useQuery(undefined, { enabled: !!isHomePage }); const userNameQuery = trpc.settings.getUserName.useQuery(undefined, { enabled: !!isHomePage });
const dueTodayQuery = trpc.tasks.dueToday.useQuery(undefined, { enabled: !!isHomePage }); const dueTodayQuery = trpc.tasks.dueToday.useQuery(undefined, { enabled: !!isHomePage });
const [messages, setMessages] = useState<ChatMessage[]>([]); const chatContext = useMemo<ChatContext>(
const [input, setInput] = useState(''); () => ({ type: 'global' as const }),
const [isStreaming, setIsStreaming] = useState(false); [],
const [streamingContent, setStreamingContent] = useState(''); );
const {
messages,
input,
setInput,
isStreaming,
streamingContent,
handleSend: chatHandleSend,
} = useAIChat(chatContext);
// Daily brief state (home page only) // Daily brief state (home page only)
const [dailyBrief, setDailyBrief] = useState<string | null>(null); const [dailyBrief, setDailyBrief] = useState<string | null>(null);
@@ -57,30 +76,50 @@ export function AIChatPanel({
const briefContentRef = useRef(''); const briefContentRef = useRef('');
const hasFiredBrief = useRef(false); const hasFiredBrief = useRef(false);
const [briefExpanded, setBriefExpanded] = useState(false);
const [briefDismissed, setBriefDismissed] = useState(false);
const messagesContainerRef = useRef<HTMLDivElement | null>(null); const messagesContainerRef = useRef<HTMLDivElement | null>(null);
const streamingContentRef = useRef(''); // --- Scroll-to-user-message + shrinking placeholder ---
const chatMutation = trpc.ai.chat.useMutation(); const lastUserMsgRef = useRef<HTMLDivElement | null>(null);
const [streamingEl, setStreamingEl] = useState<HTMLDivElement | null>(null);
const [placeholderHeight, setPlaceholderHeight] = useState<number | null>(null);
const initialPlaceholderRef = useRef(0);
const pendingScrollRef = useRef(false);
const briefMutation = trpc.ai.dailyBrief.useMutation(); const briefMutation = trpc.ai.dailyBrief.useMutation();
const scrollToBottom = useCallback(() => { // When the user message appears in the list, set the placeholder and scroll it to the top
const el = messagesContainerRef.current;
if (el) el.scrollTo({ top: el.scrollHeight });
}, []);
// Reset input when curtain closes; scroll to bottom when it reopens
useEffect(() => { useEffect(() => {
if (!curtainOpen) { if (!pendingScrollRef.current) return;
setInput(''); const lastMsg = messages[messages.length - 1];
} else { if (!lastMsg || lastMsg.role !== 'user') return;
setTimeout(scrollToBottom, 50);
}
}, [curtainOpen, scrollToBottom]);
// Auto-scroll when messages change or streaming content updates pendingScrollRef.current = false;
const ph = Math.round(window.innerHeight * 0.71);
initialPlaceholderRef.current = ph;
setPlaceholderHeight(ph);
// Double-rAF: wait for the placeholder div to actually paint before scrolling
requestAnimationFrame(() => {
requestAnimationFrame(() => {
lastUserMsgRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
});
});
}, [messages]);
// Shrink placeholder in real-time as AI streaming content grows
useEffect(() => { useEffect(() => {
scrollToBottom(); if (!isStreaming || !streamingEl) return;
}, [messages, streamingContent, scrollToBottom]); const MIN_PADDING = 80;
const observer = new ResizeObserver(() => {
const contentHeight = streamingEl.getBoundingClientRect().height;
setPlaceholderHeight(Math.max(MIN_PADDING, initialPlaceholderRef.current - contentHeight));
});
observer.observe(streamingEl);
return () => observer.disconnect();
}, [isStreaming, streamingEl]);
// Auto-fire daily brief on home page // Auto-fire daily brief on home page
useEffect(() => { useEffect(() => {
@@ -116,72 +155,10 @@ export function AIChatPanel({
}, [isHomePage, hasTokenQuery.data]); // briefMutation excluded — only fire once }, [isHomePage, hasTokenQuery.data]); // briefMutation excluded — only fire once
const handleSend = useCallback(() => { const handleSend = useCallback(() => {
const trimmed = input.trim(); if (briefLoading) return;
if (!trimmed || isStreaming || briefLoading) return; pendingScrollRef.current = true;
chatHandleSend();
const userMsg: ChatMessage = { }, [briefLoading, chatHandleSend]);
id: crypto.randomUUID(),
role: 'user',
content: trimmed,
};
setMessages((prev) => [...prev, userMsg]);
setInput('');
setIsStreaming(true);
setStreamingContent('');
streamingContentRef.current = '';
const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => {
if (done) {
const finalContent = streamingContentRef.current;
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: finalContent },
]);
setStreamingContent('');
streamingContentRef.current = '';
setIsStreaming(false);
unsubscribe();
return;
}
streamingContentRef.current += token;
setStreamingContent(streamingContentRef.current);
});
chatMutation.mutate(
{
message: trimmed,
context: {
type: contextType,
...(contextType === 'project' && projectId ? { projectId } : {}),
},
},
{
onSuccess: (data) => {
if (data.error) {
unsubscribe();
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: data.error!, error: true },
]);
setStreamingContent('');
streamingContentRef.current = '';
setIsStreaming(false);
}
},
onError: (err) => {
unsubscribe();
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: err.message || 'An unexpected error occurred.', error: true },
]);
setStreamingContent('');
streamingContentRef.current = '';
setIsStreaming(false);
},
},
);
}, [input, isStreaming, briefLoading, contextType, projectId, chatMutation]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
@@ -190,92 +167,139 @@ 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) */} {/* Sticky brief toast — anchored at top when chatting */}
{!isHomePage && ( <AnimatePresence>
<div className="flex items-center gap-2 px-6 pt-4 pb-2"> {isHomePage && hasMessages && dailyBrief && !briefDismissed && (
<Badge variant="outline">{contextLabel}</Badge> <motion.div
</div> initial={{ y: -80, opacity: 0 }}
)} animate={{ y: 0, opacity: 1 }}
exit={{ y: -80, opacity: 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
className="sticky top-0 z-30 flex justify-center px-4 pt-3 pb-1"
>
<div className="w-full max-w-2xl rounded-xl border border-border/60 bg-background/80 backdrop-blur-xl shadow-[0_8px_30px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.4)] ring-1 ring-border/10">
{/* Toast header — always visible */}
<div className="flex items-center gap-2 px-4 py-2.5">
<Sparkles size={14} className="text-primary shrink-0" />
<span className="text-xs font-semibold tracking-wide text-foreground">Daily Brief</span>
<div className="flex-1" />
<button
onClick={() => setBriefExpanded((v) => !v)}
aria-label={briefExpanded ? 'Collapse brief' : 'Expand brief'}
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
>
{briefExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
<button
onClick={() => setBriefDismissed(true)}
aria-label="Dismiss brief"
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
>
<X size={14} />
</button>
</div>
{/* Collapsed: one-line preview */}
{!briefExpanded && (
<div className="px-4 pb-3 -mt-1">
<p className="text-xs text-muted-foreground truncate">
{dailyBrief.replace(/[#*_~`>-]/g, '').slice(0, 120)}...
</p>
</div>
)}
{/* Expanded: full brief content */}
<AnimatePresence>
{briefExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: [0.25, 0.1, 0.25, 1] }}
className="overflow-hidden"
>
<div className="px-4 pb-3 max-h-64 overflow-y-auto">
<ChatMarkdown content={dailyBrief} />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Scrollable messages area */} {/* Scrollable messages area */}
<ScrollArea <div className="relative flex-1 min-h-0">
className="flex-1 min-h-0" {/* Gradual blur at the bottom of messages */}
viewportRef={messagesContainerRef} {hasMessages && (
viewportClassName={ <GradualBlur
isHomePage && !hasMessages position="bottom"
? '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-center' strength={0.6}
: '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-end' height="4rem"
} divCount={10}
onWheel={handleWheel} curve="ease-out"
> opacity={0.8}
zIndex={20}
/>
)}
<ScrollArea
className="h-full"
viewportRef={messagesContainerRef}
scrollbarClassName={hasMessages ? 'z-30' : undefined}
viewportClassName={
isHomePage && !hasMessages
? '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-center'
: '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-end'
}
>
{/* Home page initial state: greeting + brief */} {/* Home page initial state: greeting + brief */}
{isHomePage && !hasMessages && ( {isHomePage && !hasMessages && (
<div className="mx-auto w-full max-w-3xl px-6 pt-8 pb-8"> <motion.div
<div className="flex flex-col gap-8"> className="mx-auto w-full max-w-4xl px-8 pt-14 pb-8"
{/* Greeting + brief grouped closely */} variants={stagger}
<div className="flex flex-col gap-1"> initial="hidden"
<div className="flex items-center justify-between gap-4 flex-wrap"> animate="show"
<h1 className="text-[30px] font-semibold" style={{ letterSpacing: '-1px' }}> >
Hello, {userName} <div className="flex flex-col" style={{ gap: 'clamp(2.5rem, 4vh, 4rem)' }}>
</h1> {/* Greeting — editorial hero moment */}
<Badge variant="secondary"> <motion.div variants={fadeUp} className="flex flex-col gap-1">
{dueCount} Task{dueCount !== 1 ? 's' : ''} due <span
</Badge> className="font-light tracking-wide text-muted-foreground"
</div> style={{ fontSize: 'clamp(1rem, 1.6vw, 1.25rem)' }}
>
{getTimeGreeting()}
</span>
<h1
className="font-bold leading-[1.05]"
style={{ fontSize: 'clamp(3.25rem, 5.5vw, 5.5rem)', letterSpacing: '-0.035em' }}
>
{userName}
<span className="text-primary ml-3 inline-block"></span>
</h1>
{dueCount > 0 && (
<p
className="text-muted-foreground mt-2"
style={{ fontSize: 'clamp(0.875rem, 1.2vw, 1.125rem)' }}
>
<span className="text-foreground font-medium">{dueCount}</span>
{' '}task{dueCount !== 1 ? 's' : ''} due today
</p>
)}
</motion.div>
{/* Daily brief */} {/* Daily brief */}
<div> <motion.div variants={fadeUp} className="max-w-3xl">
{hasTokenQuery.data === false ? ( {hasTokenQuery.data === false ? (
<div className="flex flex-col items-center gap-3 py-2"> <div className="flex flex-col items-start gap-3 py-2">
<KeyRound size={24} className="text-muted-foreground" /> <KeyRound size={20} className="text-muted-foreground" />
<p className="text-sm text-muted-foreground text-center"> <p className="text-muted-foreground" style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}>
Configure your AI provider in Settings to enable the daily brief. Configure your AI provider in Settings to enable the daily brief.
</p> </p>
<Button variant="outline" size="sm" onClick={onOpenSettings}> <Button variant="outline" size="sm" onClick={onOpenSettings}>
@@ -283,67 +307,68 @@ export function AIChatPanel({
</Button> </Button>
</div> </div>
) : briefLoading && !dailyBrief ? ( ) : briefLoading && !dailyBrief ? (
<div className="space-y-2"> <div className="space-y-3">
<Skeleton className="h-4 w-3/4" /> <Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/2" /> <Skeleton className="h-5 w-1/2" />
<Skeleton className="h-4 w-2/3" /> <Skeleton className="h-5 w-2/3" />
</div> </div>
) : dailyBrief ? ( ) : dailyBrief ? (
<ChatMarkdown content={dailyBrief} /> <ChatMarkdown content={dailyBrief} size="lg" />
) : ( ) : (
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground" style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}>
Your daily brief will appear here. Your daily brief will appear here.
</p> </p>
)} )}
</div> </motion.div>
</div>
{/* Inline input + suggestion chips */} {/* Input + suggestion links */}
<div> <motion.div variants={fadeUp} className="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 className="flex flex-wrap items-center justify-center gap-2 mt-4"> <div className="flex flex-col gap-0.5 mt-5">
{SUGGESTION_CHIPS.map((chip) => ( {SUGGESTION_CHIPS.map((chip) => (
<button <button
key={chip.label} key={chip.label}
type="button" type="button"
className="group flex items-center gap-2 rounded-full border border-border/50 bg-background/60 backdrop-blur-lg px-4 py-2 text-sm text-foreground shadow-sm ring-1 ring-border/20 transition-all hover:shadow-md hover:-translate-y-0.5 hover:border-ring/40 cursor-pointer" className="group flex items-center gap-3 py-1.5 text-muted-foreground transition-all duration-200 hover:text-foreground hover:translate-x-1 cursor-pointer text-left"
style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}
onClick={() => setInput(chip.label)} onClick={() => setInput(chip.label)}
> >
<chip.icon size={14} className="shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" /> <chip.icon
size={16}
className="shrink-0 transition-colors duration-200 group-hover:text-primary"
/>
<span>{chip.label}</span> <span>{chip.label}</span>
</button> </button>
))} ))}
</div> </div>
</div> </motion.div>
</div> </div>
</div> </motion.div>
)} )}
{/* Home page with messages: brief stays, then messages */} {/* Home page with messages: brief stays, then messages */}
{isHomePage && hasMessages && ( {isHomePage && hasMessages && (
<div className="mx-auto w-full max-w-3xl px-6 pt-8 pb-32"> <div className="mx-auto w-full max-w-6xl px-6 pt-8 pb-32">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Brief persists */}
{dailyBrief && (
<div className="mb-2">
<ChatMarkdown content={dailyBrief} />
</div>
)}
{/* Chat messages */} {/* Chat messages */}
{messages.map((msg) => { {messages.map((msg, idx) => {
const isLastMsg = idx === messages.length - 1;
if (msg.role === 'user') { if (msg.role === 'user') {
return ( return (
<div key={msg.id} className="flex justify-end"> <div
key={msg.id}
ref={isLastMsg ? lastUserMsgRef : undefined}
className="flex justify-end"
>
<div className="ml-auto max-w-[75%] rounded-2xl bg-muted px-4 py-2"> <div className="ml-auto max-w-[75%] rounded-2xl bg-muted px-4 py-2">
<ChatMarkdown content={msg.content} /> <ChatMarkdown content={msg.content} fontSize={CHAT_FONT} />
</div> </div>
</div> </div>
); );
@@ -352,7 +377,7 @@ export function AIChatPanel({
if (msg.error) { if (msg.error) {
return ( return (
<div key={msg.id} className="mr-auto max-w-[75%]"> <div key={msg.id} className="mr-auto max-w-[75%]">
<p className="text-sm text-destructive whitespace-pre-wrap"> <p style={{ fontSize: CHAT_FONT }} className="text-destructive whitespace-pre-wrap">
{msg.content} {msg.content}
</p> </p>
</div> </div>
@@ -363,10 +388,10 @@ export function AIChatPanel({
<div key={msg.id} className="mr-auto max-w-[75%]"> <div key={msg.id} className="mr-auto max-w-[75%]">
<div className="flex items-center gap-1.5 mb-1"> <div className="flex items-center gap-1.5 mb-1">
<Sparkles size={16} className="text-foreground" /> <Sparkles size={16} className="text-foreground" />
<span className="text-sm font-semibold">Adiuva</span> <span style={{ fontSize: CHAT_FONT }} className="font-semibold">Adiuva</span>
</div> </div>
<div className="pl-[22px]"> <div className="pl-[22px]">
<ChatMarkdown content={msg.content} /> <ChatMarkdown content={msg.content} fontSize={CHAT_FONT} />
</div> </div>
</div> </div>
); );
@@ -374,14 +399,14 @@ export function AIChatPanel({
{/* Streaming AI response */} {/* Streaming AI response */}
{isStreaming && ( {isStreaming && (
<div className="mr-auto max-w-[75%]"> <div ref={setStreamingEl} className="mr-auto max-w-[75%]">
<div className="flex items-center gap-1.5 mb-1"> <div className="flex items-center gap-1.5 mb-1">
<Sparkles size={16} className="text-foreground" /> <Sparkles size={16} className="text-foreground" />
<span className="text-sm font-semibold">Adiuva</span> <span style={{ fontSize: CHAT_FONT }} className="font-semibold">Adiuva</span>
</div> </div>
{streamingContent ? ( {streamingContent ? (
<div className="pl-[22px]"> <div className="pl-[22px]">
<ChatMarkdown content={streamingContent} /> <ChatMarkdown content={streamingContent} fontSize={CHAT_FONT} />
</div> </div>
) : ( ) : (
<div className="space-y-2 pl-[22px]"> <div className="space-y-2 pl-[22px]">
@@ -391,84 +416,36 @@ export function AIChatPanel({
)} )}
</div> </div>
)} )}
{/* Placeholder: fills viewport after user message, shrinks as AI responds */}
{placeholderHeight !== null && (
<div
aria-hidden
style={{
height: placeholderHeight,
transition: 'height 180ms ease-out',
flexShrink: 0,
}}
/>
)}
</div> </div>
</div> </div>
)} )}
{/* Non-home messages */} {/* Non-home messages */}
{!isHomePage && hasMessages && ( </ScrollArea>
<div className="mx-auto w-full max-w-[1088px] px-6 pt-4 pb-32"> </div>
<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) { {/* Fixed input — pinned to the bottom, above the blur */}
return ( {hasMessages && (
<div key={msg.id} className="mr-auto max-w-[75%]"> <div className="absolute bottom-0 left-0 right-0 z-30 px-6 pb-5 pt-4 pointer-events-none">
<p className="text-sm text-destructive whitespace-pre-wrap"> <div className="relative pointer-events-auto mx-auto max-w-3xl">
{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>
{/* Fixed input — pinned to the bottom (hidden on home initial state) */}
{!(isHomePage && !hasMessages) && (
<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={`relative pointer-events-auto mx-auto ${isHomePage ? 'max-w-3xl' : 'max-w-[1088px]'}`}>
<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>
@@ -485,7 +462,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({
@@ -503,6 +479,7 @@ function ChatInput({
onChange={(e) => onInputChange(e.target.value)} onChange={(e) => onInputChange(e.target.value)}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
placeholder="Ask me anything..." placeholder="Ask me anything..."
aria-label="Chat message"
rows={1} rows={1}
className="flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground outline-none max-h-[7.5rem] overflow-y-auto" className="flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground outline-none max-h-[7.5rem] overflow-y-auto"
style={{ fieldSizing: 'content' } as React.CSSProperties} style={{ fieldSizing: 'content' } as React.CSSProperties}
@@ -510,6 +487,7 @@ function ChatInput({
<button <button
onClick={onSend} onClick={onSend}
disabled={!input.trim() || isStreaming} disabled={!input.trim() || isStreaming}
aria-label="Send message"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100" className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100"
> >
<ArrowUp size={16} /> <ArrowUp size={16} />
@@ -521,9 +499,12 @@ function ChatInput({
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */ /* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
function ChatMarkdown({ content }: { content: string }) { export function ChatMarkdown({ content, size = 'sm', fontSize }: { content: string; size?: 'sm' | 'lg'; fontSize?: string }) {
return ( return (
<div className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"> <div
className={`prose dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${size === 'lg' ? 'prose-base' : 'prose-sm'}`}
style={fontSize ? { fontSize } : undefined}
>
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={{

View File

@@ -0,0 +1,389 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { useNavigate, useRouterState } from '@tanstack/react-router';
import { X, ArrowUp } from 'lucide-react';
import {
useFloatingChat,
computeDualAnchor,
getChatWidth,
CHAT_HEIGHT,
PADDING,
} from '@/context/FloatingChatContext';
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
import { ChatMarkdown } from '@/components/ai/AIChatPanel';
import { Skeleton } from '@/components/ui/skeleton';
import { trpc } from '@/lib/trpc';
/** Map section IDs to their routes for cross-page navigation */
const SECTION_ROUTES: Record<string, string> = {
'project-summary': 'project',
'project-timeline': 'project',
'project-tasks': 'project',
'project-notes': 'project',
'tasks-overview': '/tasks',
'tasks-list': '/tasks',
'timeline-chart': '/timeline',
'note-editor': 'note',
};
function FloatingChatInner() {
const { state, sections, close, setMorphTarget, moveToSection, updatePosition, setPendingSection } = useFloatingChat();
const utils = trpc.useUtils();
const navigate = useNavigate();
const routerState = useRouterState();
const prevPathRef = useRef(routerState.location.pathname);
// Active section lookup
const activeSection = sections.get(state.activeSectionId ?? '');
// Chat context derived from active section
const chatContext = useMemo<ChatContext>(
() => ({
type: activeSection?.projectId ? 'project' : 'global',
projectId: activeSection?.projectId,
uiContext: activeSection?.label,
}),
[activeSection?.projectId, activeSection?.label],
);
// Handle [SECTION:xxx] tags from AI responses
const handleSectionTag = useCallback((sectionId: string) => {
// Same-page: section is already registered
const targetSection = sections.get(sectionId);
if (targetSection) {
moveToSection(sectionId);
targetSection.ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
return;
}
// Cross-page: section not registered, navigate to its route
const route = SECTION_ROUTES[sectionId];
if (!route) return;
setPendingSection({ sectionId });
if (route === 'project' && state.projectId) {
// Navigate to the project page (stay on same project)
// Project sections re-register on mount and pendingSection will auto-open
void navigate({ to: '/projects', search: { projectId: state.projectId } });
} else if (route.startsWith('/')) {
void navigate({ to: route });
}
// 'note' type requires noteId — skip cross-page for now
}, [sections, moveToSection, setPendingSection, state.projectId, navigate]);
const {
messages,
input,
setInput,
isStreaming,
streamingContent,
handleSend,
clearMessages,
} = useAIChat(chatContext, { onSectionTag: handleSectionTag });
const containerRef = useRef<HTMLDivElement>(null);
// ---- Close on Escape ----
useEffect(() => {
if (!state.isOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
close();
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [state.isOpen, close]);
// ---- Close on route change (unless cross-page navigation pending) ----
useEffect(() => {
const currentPath = routerState.location.pathname;
if (prevPathRef.current !== currentPath && state.isOpen && !state.pendingSection) {
close();
}
prevPathRef.current = currentPath;
}, [routerState.location.pathname, state.isOpen, state.pendingSection, close]);
// ---- Clear messages on close ----
const prevOpenRef = useRef(state.isOpen);
useEffect(() => {
if (prevOpenRef.current && !state.isOpen) {
clearMessages();
}
prevOpenRef.current = state.isOpen;
}, [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 ----
useEffect(() => {
if (!state.isOpen) return;
const handler = () => {
// Re-anchor if the container would go offscreen
const el = containerRef.current;
if (el) {
const rect = el.getBoundingClientRect();
if (rect.right > window.innerWidth || rect.bottom > window.innerHeight) {
el.style.left = `${Math.max(PADDING, Math.min(state.position.x, window.innerWidth - getChatWidth() - PADDING))}px`;
el.style.top = `${Math.max(PADDING, Math.min(state.position.y, window.innerHeight - CHAT_HEIGHT - PADDING))}px`;
}
}
};
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, [state.isOpen, state.position.x, state.position.y]);
// ---- Scroll tracking: dual-anchor repositioning ----
useEffect(() => {
if (!state.isOpen || !state.activeSectionId) return;
const section = sections.get(state.activeSectionId);
if (!section || section.anchorMode === 'right-margin') return;
const el = section.ref.current;
if (!el) return;
// Find scrollable ancestor
let scrollParent: HTMLElement | null = el.parentElement;
while (scrollParent) {
const style = getComputedStyle(scrollParent);
if (style.overflow === 'auto' || style.overflow === 'scroll' ||
style.overflowY === 'auto' || style.overflowY === 'scroll') {
break;
}
// Also check for Radix ScrollArea viewport
if (scrollParent.hasAttribute('data-radix-scroll-area-viewport')) break;
scrollParent = scrollParent.parentElement;
}
if (!scrollParent) return;
let rafId: number | null = null;
const handleScroll = () => {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
const newPos = computeDualAnchor(section);
if (newPos) {
updatePosition(newPos);
}
// null = fully off-screen → freeze (do nothing)
});
};
scrollParent.addEventListener('scroll', handleScroll, { passive: true });
return () => {
scrollParent.removeEventListener('scroll', handleScroll);
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, [state.isOpen, state.activeSectionId, sections, updatePosition]);
// ---- Auto-scroll messages ----
const scrollRef = useRef<HTMLDivElement>(null);
const scrollToBottom = useCallback(() => {
const el = scrollRef.current;
if (el) el.scrollTo({ top: el.scrollHeight });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent, scrollToBottom]);
// ---- Auto-focus input on open ----
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (state.isOpen) {
const timer = setTimeout(() => inputRef.current?.focus(), 100);
return () => clearTimeout(timer);
}
}, [state.isOpen]);
// ---- Input handling ----
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const hasMessages = messages.length > 0 || isStreaming;
// Expand the messages panel upward if there's enough space above the input bar,
// otherwise expand downward. 320px = 300px max-h + 8px gap + 12px buffer.
const expandUp = state.position.y >= 320;
return (
<AnimatePresence>
{state.isOpen && (
<motion.div
ref={containerRef}
key="floating-chat"
layout
layoutId={state.morphTargetId ?? undefined}
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 12 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
style={{
position: 'fixed',
left: state.position.x,
top: state.position.y,
width: state.position.width,
zIndex: 9999,
}}
className="relative"
>
{/* ---- Messages panel — floats above or below the input bar ---- */}
<AnimatePresence>
{hasMessages && (
<motion.div
key="messages-panel"
initial={{ opacity: 0, scale: 0.97, y: expandUp ? 8 : -8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: expandUp ? 8 : -8 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
style={{
position: 'absolute',
width: '100%',
...(expandUp
? { bottom: 'calc(100% + 8px)' }
: { top: 'calc(100% + 8px)' }),
}}
className="rounded-2xl overflow-hidden"
>
<div
ref={scrollRef}
className="max-h-[300px] overflow-y-auto rounded-2xl [&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/40"
>
<div className="flex flex-col gap-2.5 p-3">
{messages.map((msg) => {
if (msg.role === 'user') {
return (
<div key={msg.id} className="flex justify-end">
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-br-md px-3.5 py-2">
<p className="text-xs whitespace-pre-wrap leading-relaxed text-foreground">
{msg.content}
</p>
</div>
</div>
);
}
if (msg.error) {
return (
<div key={msg.id} className="flex justify-start">
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2 !border-destructive/30">
<p className="text-xs text-destructive whitespace-pre-wrap leading-relaxed">
{msg.content}
</p>
</div>
</div>
);
}
return (
<div key={msg.id} className="flex justify-start">
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2">
<div className="text-xs text-foreground">
<ChatMarkdown content={msg.content} />
</div>
</div>
</div>
);
})}
{/* Streaming */}
{isStreaming && (
<div className="flex justify-start">
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2">
{streamingContent ? (
<div className="text-xs text-foreground">
<ChatMarkdown content={streamingContent} />
</div>
) : (
<div className="space-y-1.5 py-0.5">
<Skeleton className="h-3 w-36" />
<Skeleton className="h-3 w-24" />
</div>
)}
</div>
</div>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* ---- Floating input bar ---- */}
<div className="glass-surface relative rounded-2xl transition-shadow focus-within:shadow-[0_8px_60px_-8px_rgba(0,0,0,0.35)]">
{/* Close button */}
<button
onClick={close}
className="absolute -top-1.5 -right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-muted/90 backdrop-blur-sm border border-border/50 shadow-sm hover:bg-muted transition-colors z-10"
>
<X size={10} />
</button>
<div className="flex items-center gap-2 px-3 py-2.5">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Ask about ${activeSection?.label ?? 'this section'}...`}
rows={1}
className="flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground/60 outline-none max-h-20 overflow-y-auto"
style={{ fieldSizing: 'content' } as React.CSSProperties}
/>
<button
onClick={() => handleSend()}
disabled={!input.trim() || isStreaming}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed"
>
<ArrowUp size={14} />
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
export function FloatingChatPortal() {
return createPortal(<FloatingChatInner />, document.body);
}

View File

@@ -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,
@@ -18,6 +16,7 @@ import {
Palette Palette
} from 'lucide-react'; } from 'lucide-react';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
import { useDoubleClickAI } from '@/hooks/useDoubleClickAI';
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -30,14 +29,13 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
SidebarProvider, SidebarProvider,
SidebarTrigger,
useSidebar, useSidebar,
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuPortal, DropdownMenuPortal,
DropdownMenuSub, DropdownMenuSub,
DropdownMenuTrigger, DropdownMenuTrigger,
@@ -55,7 +53,9 @@ import {
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { AIChatPanel } from '@/components/ai/AIChatPanel'; import { AIChatPanel } from '@/components/ai/AIChatPanel';
import { FloatingChatPortal } from '@/components/ai/FloatingChat';
import { useTheme } from '@/components/theme-provider'; import { useTheme } from '@/components/theme-provider';
import { FloatingChatProvider } from '@/context/FloatingChatContext';
const NAV_ITEMS = [ const NAV_ITEMS = [
{ to: '/', icon: House, label: 'Home' }, { to: '/', icon: House, label: 'Home' },
@@ -68,21 +68,17 @@ interface AppShellProps {
children: React.ReactNode; children: React.ReactNode;
} }
/** Walk up the DOM to find the nearest scrollable ancestor. */ export function AppShell({ children }: AppShellProps) {
function findScrollableAncestor(el: Element | null): Element | null { return (
if (!el || el === document.body) return null; <FloatingChatProvider>
const style = window.getComputedStyle(el); <AppShellInner>{children}</AppShellInner>
const overflowY = style.overflowY; </FloatingChatProvider>
if ( );
(overflowY === 'auto' || overflowY === 'scroll') &&
el.scrollHeight > el.clientHeight
) {
return el;
}
return findScrollableAncestor(el.parentElement);
} }
export function AppShell({ children }: AppShellProps) { function AppShellInner({ children }: AppShellProps) {
useDoubleClickAI();
const collapsedQuery = trpc.settings.getSidebarCollapsed.useQuery(undefined, { const collapsedQuery = trpc.settings.getSidebarCollapsed.useQuery(undefined, {
staleTime: Infinity, staleTime: Infinity,
}); });
@@ -119,142 +115,33 @@ export function AppShell({ 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} <div className="relative flex flex-col h-full">
isHomePage={isHomePage} <header className="flex items-center gap-2 p-2 md:hidden">
/> <SidebarTrigger />
</header>
{/* 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} {children}
</div>
{/* 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} />
)}
<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>
</motion.div>
)} )}
</SidebarInset> </SidebarInset>
</SidebarProvider> </SidebarProvider>
{/* Floating AI Chat — portal to document.body */}
<FloatingChatPortal />
{/* AI Token Dialog — rendered outside Sidebar to avoid layout conflicts */} {/* AI Token Dialog — rendered outside Sidebar to avoid layout conflicts */}
<Dialog open={tokenDialogOpen} onOpenChange={(open) => { <Dialog open={tokenDialogOpen} onOpenChange={(open) => {
setTokenDialogOpen(open); setTokenDialogOpen(open);
@@ -278,13 +165,13 @@ export function AppShell({ children }: AppShellProps) {
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Your token is stored securely in the OS keychain. Your token is stored securely in the OS keychain.
{hasTokenQuery.data === true && ( {hasTokenQuery.data === true && (
<span className="text-green-600 ml-1">A token is currently stored.</span> <span className="text-green-600 dark:text-green-400 ml-1">A token is currently stored.</span>
)} )}
</p> </p>
</div> </div>
<DialogFooter> <DialogFooter>
{saved && ( {saved && (
<span className="flex items-center gap-1 text-sm text-green-600 mr-auto"> <span className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400 mr-auto">
<Check size={14} /> <Check size={14} />
Saved Saved
</span> </span>
@@ -298,17 +185,16 @@ export function AppShell({ 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();
@@ -361,7 +247,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>

View File

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

View File

@@ -1,9 +1,10 @@
import { Fragment, useMemo, useState } from 'react'; import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import { Sparkles, FileText, CheckCircle2, Milestone, Plus } from 'lucide-react'; import { Sparkles, FileText, CheckCircle2, Milestone, Plus } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item'; import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
import { import {
Breadcrumb, Breadcrumb,
@@ -16,6 +17,7 @@ import { KanbanBoard } from './KanbanBoard';
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart'; import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog'; import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog'; import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog';
import { useFloatingChat } from '@/context/FloatingChatContext';
type ProjectDetailProps = { type ProjectDetailProps = {
projectId: string; projectId: string;
@@ -26,6 +28,26 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
const [addCheckpointOpen, setAddCheckpointOpen] = useState(false); const [addCheckpointOpen, setAddCheckpointOpen] = useState(false);
const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null); const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
// AI section refs
const summaryRef = useRef<HTMLDivElement>(null);
const timelineRef = useRef<HTMLDivElement>(null);
const tasksRef = useRef<HTMLDivElement>(null);
const notesRef = useRef<HTMLDivElement>(null);
const { registerSection, unregisterSection } = useFloatingChat();
useEffect(() => {
registerSection({ id: 'project-summary', label: 'Project Summary', ref: summaryRef, projectId });
registerSection({ id: 'project-timeline', label: 'Project Timeline', ref: timelineRef, projectId });
registerSection({ id: 'project-tasks', label: 'Tasks', ref: tasksRef, projectId });
registerSection({ id: 'project-notes', label: 'Notes', ref: notesRef, projectId });
return () => {
unregisterSection('project-summary');
unregisterSection('project-timeline');
unregisterSection('project-tasks');
unregisterSection('project-notes');
};
}, [projectId, registerSection, unregisterSection]);
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId }); const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
const { data: clientsList } = trpc.clients.list.useQuery(); const { data: clientsList } = trpc.clients.list.useQuery();
@@ -146,8 +168,17 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground"> <div className="p-6 flex flex-col gap-6">
Loading project... <div className="flex flex-col gap-1">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-56" />
</div>
<div className="grid grid-cols-3 gap-4">
<Skeleton className="h-20 rounded-lg" />
<Skeleton className="h-20 rounded-lg" />
<Skeleton className="h-20 rounded-lg" />
</div>
<Skeleton className="h-16 rounded-lg" />
</div> </div>
); );
} }
@@ -161,7 +192,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 && (
@@ -181,54 +212,57 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
<h1 className="text-2xl font-semibold text-foreground">{project.name}</h1> <h1 className="text-2xl font-semibold text-foreground">{project.name}</h1>
</div> </div>
{/* Stat Cards */} {/* Project Summary Section */}
<div className="grid grid-cols-3 gap-4"> <div ref={summaryRef} data-ai-section="project-summary" className="flex flex-col gap-6">
<Item variant="muted"> {/* Stat Cards */}
<ItemMedia variant="icon"> <div className="grid grid-cols-3 gap-4">
<FileText /> <Item variant="muted">
</ItemMedia> <ItemMedia variant="icon">
<ItemContent> <FileText />
<ItemTitle>{notesCount}</ItemTitle> </ItemMedia>
<ItemDescription>Notes</ItemDescription> <ItemContent>
</ItemContent> <ItemTitle>{notesCount}</ItemTitle>
</Item> <ItemDescription>Notes</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted"> <Item variant="muted">
<ItemMedia variant="icon"> <ItemMedia variant="icon">
<CheckCircle2 /> <CheckCircle2 />
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>{taskStats.done}/{taskStats.total}</ItemTitle> <ItemTitle>{taskStats.done}/{taskStats.total}</ItemTitle>
<ItemDescription>Tasks Complete</ItemDescription> <ItemDescription>Tasks Complete</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
<Item variant="muted"> <Item variant="muted">
<ItemMedia variant="icon">
<Milestone />
</ItemMedia>
<ItemContent>
<ItemTitle>{checkpointStats.approved}/{checkpointStats.total}</ItemTitle>
<ItemDescription>Checkpoints</ItemDescription>
</ItemContent>
</Item>
</div>
{/* AI Project Summary */}
<Item variant="outline">
<ItemMedia variant="icon"> <ItemMedia variant="icon">
<Milestone /> <Sparkles />
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>{checkpointStats.approved}/{checkpointStats.total}</ItemTitle> <ItemTitle>AI Project Summary</ItemTitle>
<ItemDescription>Checkpoints</ItemDescription> <ItemDescription>
{project.aiSummary || 'AI summary will appear here'}
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
</div> </div>
{/* AI Project Summary */}
<Item variant="outline">
<ItemMedia variant="icon">
<Sparkles />
</ItemMedia>
<ItemContent>
<ItemTitle>AI Project Summary</ItemTitle>
<ItemDescription>
{project.aiSummary || 'AI summary will appear here'}
</ItemDescription>
</ItemContent>
</Item>
{/* Project Timeline */} {/* Project Timeline */}
<div className="flex flex-col gap-3"> <div ref={timelineRef} data-ai-section="project-timeline" className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Project Timeline</h2> <h2 className="text-lg font-semibold">Project Timeline</h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -306,7 +340,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
</div> </div>
{/* Tasks Kanban */} {/* Tasks Kanban */}
<div className="flex flex-col gap-3"> <div ref={tasksRef} data-ai-section="project-tasks" className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Tasks</h2> <h2 className="text-lg font-semibold">Tasks</h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -372,7 +406,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
</div> </div>
{/* Notes */} {/* Notes */}
<div className="flex flex-col gap-3"> <div ref={notesRef} data-ai-section="project-notes" className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Notes</h2> <h2 className="text-lg font-semibold">Notes</h2>
<Button <Button

View File

@@ -322,7 +322,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
if (editCreatingClient && editNewClientName.trim()) { if (editCreatingClient && editNewClientName.trim()) {
// Create a new client // Create a new client
const result = await createClientMutation.mutateAsync({ name: editNewClientName.trim() }); const result = await createClientMutation.mutateAsync({ name: editNewClientName.trim() });
let parentId = result.id; const parentId = result.id;
if (editCreatingSubClient && editNewSubClientName.trim()) { if (editCreatingSubClient && editNewSubClientName.trim()) {
// Also create a sub-client under the new client // Also create a sub-client under the new client

View File

@@ -4,21 +4,21 @@ export function PriorityBadge({ priority }: { priority: string | null }) {
switch (priority) { switch (priority) {
case 'high': case 'high':
return ( return (
<span className="inline-flex items-center gap-1 text-xs"> <span className="inline-flex items-center gap-1 text-xs text-red-600 dark:text-red-400">
<ArrowUp className="h-3 w-3" /> <ArrowUp className="h-3 w-3" />
High High
</span> </span>
); );
case 'medium': case 'medium':
return ( return (
<span className="inline-flex items-center gap-1 text-xs"> <span className="inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
<ArrowRight className="h-3 w-3" /> <ArrowRight className="h-3 w-3" />
Medium Medium
</span> </span>
); );
case 'low': case 'low':
return ( return (
<span className="inline-flex items-center gap-1 text-xs"> <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<ArrowDown className="h-3 w-3" /> <ArrowDown className="h-3 w-3" />
Low Low
</span> </span>

View File

@@ -1,4 +1,7 @@
import { Fragment } from 'react';
import { motion } from 'framer-motion';
import { Calendar, User, Pencil, Trash2 } from 'lucide-react'; import { Calendar, User, Pencil, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
@@ -57,6 +60,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 +68,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,13 +89,21 @@ 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
className={`flex flex-col gap-1.5 px-4 py-3 rounded-md border select-none transition-colors ${ {...wrapperProps}
isDone ? 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900' : 'bg-card border-border' className={cn(
} ${onClick ? 'cursor-pointer hover:bg-accent/50' : 'cursor-default'}`} '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',
onClick ? 'cursor-pointer hover:bg-accent/50' : 'cursor-default',
)}
onClick={() => onClick?.(task)} onClick={() => onClick?.(task)}
> >
{/* Row 1: checkbox + title + description */} {/* Row 1: checkbox + title + description */}
@@ -102,7 +115,7 @@ export function TaskRow({
className="mt-0.5 shrink-0" className="mt-0.5 shrink-0"
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className={`text-sm font-semibold ${isDone ? 'line-through text-muted-foreground' : ''}`}> <div className={cn('text-sm font-medium', isDone && 'line-through text-muted-foreground')}>
{task.title} {task.title}
</div> </div>
{task.description && ( {task.description && (
@@ -129,10 +142,12 @@ export function TaskRow({
<Breadcrumb className="shrink-0"> <Breadcrumb className="shrink-0">
<BreadcrumbList> <BreadcrumbList>
{breadcrumb.map((part, i) => ( {breadcrumb.map((part, i) => (
<BreadcrumbItem key={i}> <Fragment key={i}>
{i > 0 && <BreadcrumbSeparator />} {i > 0 && <BreadcrumbSeparator />}
<span className="text-xs">{part}</span> <BreadcrumbItem>
</BreadcrumbItem> <span className="text-xs">{part}</span>
</BreadcrumbItem>
</Fragment>
))} ))}
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
@@ -146,7 +161,7 @@ export function TaskRow({
)} )}
</div> </div>
)} )}
</div> </Wrapper>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>

View File

@@ -0,0 +1,109 @@
import { useMemo } from 'react';
type Position = 'top' | 'bottom';
interface GradualBlurProps {
/** Edge to attach the blur overlay */
position?: Position;
/** Base blur strength multiplier */
strength?: number;
/** Overlay height (CSS value) */
height?: string;
/** Number of stacked blur layers (higher = smoother) */
divCount?: number;
/** Use exponential progression for stronger end blur */
exponential?: boolean;
/** Distribution curve: linear | ease-out */
curve?: 'linear' | 'ease-out';
/** Opacity applied to each blur layer */
opacity?: number;
/** z-index for the overlay */
zIndex?: number;
/** Additional class names */
className?: string;
}
const getGradientDirection = (position: Position) =>
position === 'top' ? 'to top' : 'to bottom';
export function GradualBlur({
position = 'top',
strength = 2,
height = '6rem',
divCount = 5,
exponential = false,
curve = 'linear',
opacity = 1,
zIndex = 10,
className = '',
}: GradualBlurProps) {
const blurDivs = useMemo(() => {
const divs: React.ReactNode[] = [];
const increment = 100 / divCount;
const direction = getGradientDirection(position);
const curveFunc = curve === 'ease-out'
? (p: number) => 1 - Math.pow(1 - p, 2)
: (p: number) => p;
for (let i = 1; i <= divCount; i++) {
let progress = i / divCount;
progress = curveFunc(progress);
let blurValue: number;
if (exponential) {
blurValue = Math.pow(2, progress * 4) * 0.0625 * strength;
} else {
blurValue = progress * strength;
}
const p1 = Math.round((increment * i - increment) * 10) / 10;
const p2 = Math.round(increment * i * 10) / 10;
const p3 = Math.round((increment * i + increment) * 10) / 10;
const p4 = Math.round((increment * i + increment * 2) * 10) / 10;
let gradient = `transparent ${p1}%, black ${p2}%`;
if (p3 <= 100) gradient += `, black ${p3}%`;
if (p4 <= 100) gradient += `, transparent ${p4}%`;
const maskImage = `linear-gradient(${direction}, ${gradient})`;
divs.push(
<div
key={i}
style={{
position: 'absolute',
inset: 0,
maskImage,
WebkitMaskImage: maskImage,
backdropFilter: `blur(${blurValue.toFixed(3)}rem)`,
WebkitBackdropFilter: `blur(${blurValue.toFixed(3)}rem)`,
opacity,
}}
/>,
);
}
return divs;
}, [position, strength, divCount, exponential, curve, opacity]);
return (
<div
className={className}
style={{
position: 'absolute',
[position]: 0,
left: 0,
right: 0,
height,
pointerEvents: 'none',
zIndex,
overflow: 'hidden',
}}
>
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
{blurDivs}
</div>
</div>
);
}

View File

@@ -1,42 +0,0 @@
import * as React from "react"
import { HoverCard as HoverCardPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -8,10 +8,12 @@ function ScrollArea({
children, children,
viewportRef, viewportRef,
viewportClassName, viewportClassName,
scrollbarClassName,
...props ...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & { }: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
viewportRef?: React.Ref<HTMLDivElement>; viewportRef?: React.Ref<HTMLDivElement>;
viewportClassName?: string; viewportClassName?: string;
scrollbarClassName?: string;
}) { }) {
return ( return (
<ScrollAreaPrimitive.Root <ScrollAreaPrimitive.Root
@@ -29,7 +31,7 @@ function ScrollArea({
> >
{children} {children}
</ScrollAreaPrimitive.Viewport> </ScrollAreaPrimitive.Viewport>
<ScrollBar /> <ScrollBar className={scrollbarClassName} />
<ScrollAreaPrimitive.Corner /> <ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root> </ScrollAreaPrimitive.Root>
) )
@@ -45,7 +47,7 @@ function ScrollBar({
data-slot="scroll-area-scrollbar" data-slot="scroll-area-scrollbar"
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"flex touch-none p-px transition-colors select-none", "flex touch-none p-px transition-colors select-none z-50",
orientation === "vertical" && orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent", "h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" && orientation === "horizontal" &&

View File

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

View File

@@ -1,6 +1,8 @@
@import '@fontsource/geist/300.css';
@import '@fontsource/geist/400.css'; @import '@fontsource/geist/400.css';
@import '@fontsource/geist/500.css'; @import '@fontsource/geist/500.css';
@import '@fontsource/geist/600.css'; @import '@fontsource/geist/600.css';
@import '@fontsource/geist/700.css';
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@@ -50,73 +52,113 @@
:root { :root {
--radius: 0.625rem; --radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823); /* #f4edf3 - Light Pinkish-White Canvas */
--card: oklch(1 0 0); --background: oklch(0.945 0.012 328.5);
--card-foreground: oklch(0.141 0.005 285.823); /* #040404 - Almost Black Text */
--popover: oklch(1 0 0); --foreground: oklch(0.145 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885); --card: oklch(0.945 0.012 328.5);
--primary-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.145 0 0);
--secondary: oklch(0.967 0.001 286.375); --popover: oklch(0.945 0.012 328.5);
--secondary-foreground: oklch(0.21 0.006 285.885); --popover-foreground: oklch(0.145 0 0);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938); /* #fbc881 - Golden Yellow Accent */
--accent: oklch(0.967 0.001 286.375); --primary: oklch(0.838 0.117 76.8);
--accent-foreground: oklch(0.21 0.006 285.885); --primary-foreground: oklch(0.145 0 0);
/* #8a8ea9 - Slate Blue/Gray */
--secondary: oklch(0.627 0.041 274.5);
--secondary-foreground: oklch(0.945 0.012 328.5);
/* #c8c3cd - Light Gray/Purple */
--muted: oklch(0.811 0.014 300.2);
--muted-foreground: oklch(0.627 0.041 274.5);
--accent: oklch(0.811 0.014 300.2);
--accent-foreground: oklch(0.145 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0); --destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32); --border: oklch(0.811 0.014 300.2);
--ring: oklch(0.705 0.015 286.067); --input: oklch(0.811 0.014 300.2);
--ring: oklch(0.838 0.117 76.8);
/* Kept your original chart colors */
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429); --chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08); --chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823); /* Sidebar uses the custom palette */
--sidebar-primary: oklch(0.21 0.006 285.885); --sidebar: oklch(0.945 0.012 328.5);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375); --sidebar-primary: oklch(0.838 0.117 76.8);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-primary-foreground: oklch(0.145 0 0);
--sidebar-border: oklch(0.92 0.004 286.32); --sidebar-accent: oklch(0.811 0.014 300.2);
--sidebar-ring: oklch(0.705 0.015 286.067); --sidebar-accent-foreground: oklch(0.145 0 0);
--sidebar-border: oklch(0.811 0.014 300.2);
--sidebar-ring: oklch(0.838 0.117 76.8);
} }
.dark { .dark {
--background: oklch(0.141 0.005 285.823); /* #0c0c0c - Deepest black for the main canvas */
--background: oklch(0.15 0 0);
/* #fbfbfb - Crisp white for primary text */
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
/* Cards use the main background but are defined by borders */
--card: oklch(0.15 0 0);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885); --popover: oklch(0.15 0 0);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885); /* #fbfbfb - Primary actions (like the active white circle menu item) */
--secondary: oklch(0.274 0.006 286.033); --primary: oklch(0.985 0 0);
/* #0c0c0c - Dark text/icons inside primary buttons */
--primary-foreground: oklch(0.15 0 0);
/* #323232 - Dark gray for secondary surfaces and button backgrounds */
--secondary: oklch(0.335 0 0);
/* #fbfbfb - White text on secondary surfaces */
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067); /* #323232 - Dark gray for muted backgrounds */
--accent: oklch(0.274 0.006 286.033); --muted: oklch(0.335 0 0);
/* #77797b - Mid gray for muted/secondary text (like "ELEVATE YOUR...") */
--muted-foreground: oklch(0.555 0 0);
/* #323232 - Hover states */
--accent: oklch(0.335 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive: oklch(0.704 0.191 22.216); /* Kept original dark red */
--destructive-foreground: oklch(0.985 0 0); --destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); /* #323232 - Distinct dark gray borders for the cards/panels */
--ring: oklch(0.552 0.016 285.938); --border: oklch(0.335 0 0);
--input: oklch(0.335 0 0);
/* #bab7ba - Lighter gray for focus rings to stand out against dark borders */
--ring: oklch(0.765 0 0);
/* Kept your original chart colors */
--chart-1: oklch(0.488 0.243 264.376); --chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48); --chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08); --chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9); --chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439); --chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
/* Sidebar mapped to the new sleek dark palette */
--sidebar: oklch(0.15 0 0);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.985 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.15 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033); --sidebar-accent: oklch(0.335 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(0.335 0 0);
--sidebar-ring: oklch(0.552 0.016 285.938); --sidebar-ring: oklch(0.765 0 0);
} }
@layer base { @layer base {
@@ -142,6 +184,78 @@ body {
overflow: hidden; overflow: hidden;
} }
/* ---- Glass Surface (ReactBits-style) ---- */
/*
* Gradient border via padding-box/border-box background split —
* most reliable technique in Chromium/Electron; no pseudo-element mask needed.
*/
.glass-surface {
border: 1px solid transparent;
background:
/* glass fill — clips to padding-box (inside the border) */
rgba(255, 255, 255, 0.55) padding-box,
/* gradient border — clips to border-box (the 1px border strip) */
linear-gradient(
145deg,
rgba(255, 255, 255, 0.90) 0%,
rgba(200, 195, 205, 0.40) 40%,
rgba(200, 195, 205, 0.20) 100%
) border-box;
backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%);
box-shadow:
0 4px 48px rgba(0, 0, 0, 0.10),
0 1px 2px rgba(0, 0, 0, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.80);
}
.dark .glass-surface {
background:
rgba(255, 255, 255, 0.05) padding-box,
linear-gradient(
145deg,
rgba(255, 255, 255, 0.18) 0%,
rgba(255, 255, 255, 0.04) 40%,
rgba(255, 255, 255, 0.08) 100%
) border-box;
box-shadow:
0 4px 48px rgba(0, 0, 0, 0.50),
0 1px 2px rgba(0, 0, 0, 0.20),
inset 0 1px 0 rgba(255, 255, 255, 0.10);
}
/* Subtle variant — same gradient border, much more transparent fill */
.glass-surface-subtle {
border: 1px solid transparent;
background:
rgba(255, 255, 255, 0.20) padding-box,
linear-gradient(
145deg,
rgba(255, 255, 255, 0.70) 0%,
rgba(200, 195, 205, 0.25) 40%,
rgba(200, 195, 205, 0.10) 100%
) border-box;
backdrop-filter: blur(16px) saturate(160%);
-webkit-backdrop-filter: blur(16px) saturate(160%);
box-shadow:
0 2px 12px rgba(0, 0, 0, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.60);
}
.dark .glass-surface-subtle {
background:
rgba(255, 255, 255, 0.03) padding-box,
linear-gradient(
145deg,
rgba(255, 255, 255, 0.12) 0%,
rgba(255, 255, 255, 0.02) 40%,
rgba(255, 255, 255, 0.05) 100%
) border-box;
box-shadow:
0 2px 12px rgba(0, 0, 0, 0.30),
inset 0 1px 0 rgba(255, 255, 255, 0.07);
}
/* Crepe editor layout */ /* Crepe editor layout */
.milkdown-container { .milkdown-container {
display: flex; display: flex;

View File

@@ -1,7 +1,7 @@
import { useState, useCallback, useRef } from 'react'; import { useState, useCallback, useRef } from 'react';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
export interface ChatMessage { interface ChatMessage {
id: string; id: string;
role: 'user' | 'assistant'; role: 'user' | 'assistant';
content: string; content: string;
@@ -14,7 +14,7 @@ export interface ChatContext {
uiContext?: string; uiContext?: string;
} }
export interface UseAIChatReturn { interface UseAIChatReturn {
messages: ChatMessage[]; messages: ChatMessage[];
input: string; input: string;
setInput: (v: string) => void; setInput: (v: string) => void;
@@ -24,7 +24,11 @@ export interface UseAIChatReturn {
clearMessages: () => void; clearMessages: () => void;
} }
export function useAIChat(defaultContext: ChatContext): UseAIChatReturn { interface UseAIChatOptions {
onSectionTag?: (sectionId: string) => void;
}
export function useAIChat(defaultContext: ChatContext, options?: UseAIChatOptions): UseAIChatReturn {
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false); const [isStreaming, setIsStreaming] = useState(false);
@@ -58,7 +62,15 @@ export function useAIChat(defaultContext: ChatContext): UseAIChatReturn {
const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => { const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => {
if (done) { if (done) {
const finalContent = streamingContentRef.current; let finalContent = streamingContentRef.current;
// Parse and strip [SECTION:xxx] tag from AI response
const sectionMatch = finalContent.match(/^\[SECTION:([\w-]+)\]\s*/);
if (sectionMatch) {
finalContent = finalContent.slice(sectionMatch[0].length);
options?.onSectionTag?.(sectionMatch[1]!);
}
setMessages((prev) => [ setMessages((prev) => [
...prev, ...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: finalContent }, { id: crypto.randomUUID(), role: 'assistant', content: finalContent },

View File

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

View File

@@ -15,6 +15,7 @@ interface ElectronTRPC {
interface ElectronAI { interface ElectronAI {
onStreamChunk: (cb: (data: { token: string; done: boolean }) => void) => () => void; onStreamChunk: (cb: (data: { token: string; done: boolean }) => void) => () => void;
onAction: (cb: (data: { type: string; taskId?: string; count?: number }) => void) => () => void;
} }
declare global { declare global {

View File

@@ -19,6 +19,7 @@ import {
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
import { MilkdownEditor } from '@/components/notes/MilkdownEditor'; import { MilkdownEditor } from '@/components/notes/MilkdownEditor';
import { useFloatingChat } from '@/context/FloatingChatContext';
export const Route = createFileRoute('/notes/$noteId')({ export const Route = createFileRoute('/notes/$noteId')({
component: NoteDetailPage, component: NoteDetailPage,
@@ -29,6 +30,21 @@ function NoteDetailPage() {
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const { data: note, isLoading } = trpc.notes.get.useQuery({ id: noteId }); const { data: note, isLoading } = trpc.notes.get.useQuery({ id: noteId });
// AI section — register with right-margin anchor mode
const editorRef = useRef<HTMLDivElement>(null);
const { registerSection, unregisterSection } = useFloatingChat();
const noteProjectId = note?.projectId ?? undefined;
useEffect(() => {
registerSection({
id: 'note-editor',
label: 'Note Editor',
ref: editorRef,
projectId: noteProjectId,
anchorMode: 'right-margin',
});
return () => unregisterSection('note-editor');
}, [noteId, noteProjectId, registerSection, unregisterSection]);
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -139,7 +155,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
@@ -188,7 +204,7 @@ function NoteDetailPage() {
</div> </div>
{/* Editor */} {/* Editor */}
<ScrollArea className="flex-1 min-h-0"> <ScrollArea ref={editorRef} data-ai-section="note-editor" className="flex-1 min-h-0">
<div className="px-4 py-4"> <div className="px-4 py-4">
<MilkdownEditor <MilkdownEditor
key={noteId} key={noteId}

View File

@@ -1,9 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
import { useState, useCallback, useMemo } from 'react'; import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { useFloatingChat } from '@/context/FloatingChatContext';
import { import {
ClipboardCheck, ClipboardCheck,
ListTodo, ListTodo,
Loader2, Clock,
CheckCircle2, CheckCircle2,
Plus, Plus,
Search, Search,
@@ -40,6 +41,19 @@ const ORDER_LABELS: Record<OrderBy, string> = {
}; };
function TasksPage() { function TasksPage() {
// AI section refs
const overviewRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const { state: floatingState, registerSection, unregisterSection } = useFloatingChat();
useEffect(() => {
registerSection({ id: 'tasks-overview', label: 'Tasks Overview', ref: overviewRef });
registerSection({ id: 'tasks-list', label: 'Task List', ref: listRef });
return () => {
unregisterSection('tasks-overview');
unregisterSection('tasks-list');
};
}, [registerSection, unregisterSection]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all'); const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
@@ -110,9 +124,9 @@ 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 ref={overviewRef} data-ai-section="tasks-overview" className="grid grid-cols-4 gap-4">
<Item variant="muted"> <Item variant="muted">
<ItemMedia variant="icon"> <ItemMedia variant="icon">
<ClipboardCheck /> <ClipboardCheck />
@@ -133,7 +147,7 @@ function TasksPage() {
</Item> </Item>
<Item variant="muted" className="bg-sky-50 dark:bg-sky-950/30"> <Item variant="muted" className="bg-sky-50 dark:bg-sky-950/30">
<ItemMedia variant="icon"> <ItemMedia variant="icon">
<Loader2 /> <Clock />
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>{stats.inProgress}</ItemTitle> <ItemTitle>{stats.inProgress}</ItemTitle>
@@ -151,50 +165,52 @@ function TasksPage() {
</Item> </Item>
</div> </div>
{/* Search + Order By */} {/* Task List Section */}
<div className="flex items-center gap-3"> <div ref={listRef} data-ai-section="tasks-list" className="flex flex-col gap-6">
<InputGroup className="flex-1"> {/* Search + Order By */}
<InputGroupAddon> <div className="flex items-center gap-3">
<Search /> <InputGroup className="flex-1">
</InputGroupAddon> <InputGroupAddon>
<InputGroupInput <Search />
placeholder="Search tasks or projects..." </InputGroupAddon>
value={search} <InputGroupInput
onChange={(e) => handleSearchChange(e.target.value)} placeholder="Search tasks or projects..."
/> value={search}
</InputGroup> onChange={(e) => handleSearchChange(e.target.value)}
<Select value={orderBy} onValueChange={(v) => setOrderBy(v as OrderBy)}> />
<SelectTrigger className="w-[180px]"> </InputGroup>
<SelectValue placeholder="Order by" /> <Select value={orderBy} onValueChange={(v) => setOrderBy(v as OrderBy)}>
</SelectTrigger> <SelectTrigger className="w-[180px]">
<SelectContent> <SelectValue placeholder="Order by" />
{(Object.entries(ORDER_LABELS) as [OrderBy, string][]).map(([key, label]) => ( </SelectTrigger>
<SelectItem key={key} value={key}> <SelectContent>
{label} {(Object.entries(ORDER_LABELS) as [OrderBy, string][]).map(([key, label]) => (
</SelectItem> <SelectItem key={key} value={key}>
))} {label}
</SelectContent> </SelectItem>
</Select> ))}
</div> </SelectContent>
</Select>
</div>
{/* Status Filter Tabs + New Task Button */} {/* Status Filter Tabs + New Task Button */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}> <Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
<TabsList> <TabsList>
<TabsTrigger value="all">All</TabsTrigger> <TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="todo">To Do</TabsTrigger> <TabsTrigger value="todo">To Do</TabsTrigger>
<TabsTrigger value="in_progress">In Progress</TabsTrigger> <TabsTrigger value="in_progress">In Progress</TabsTrigger>
<TabsTrigger value="done">Completed</TabsTrigger> <TabsTrigger value="done">Completed</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
<Button size="sm" onClick={() => setDialogOpen(true)}> <Button size="sm" onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> <Plus className="h-4 w-4 mr-1" />
New Task New Task
</Button> </Button>
</div> </div>
{/* Task List */} {/* Task List */}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{tasksList.length === 0 ? ( {tasksList.length === 0 ? (
<Empty> <Empty>
<EmptyHeader> <EmptyHeader>
@@ -216,9 +232,15 @@ 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
}
/> />
)) ))
)} )}
</div>
</div> </div>
<NewTaskDialog open={dialogOpen} onOpenChange={setDialogOpen} /> <NewTaskDialog open={dialogOpen} onOpenChange={setDialogOpen} />

View File

@@ -1,11 +1,13 @@
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
import { useState, useMemo } from 'react'; import { useEffect, useRef, useState, useMemo } from 'react';
import { Plus } from 'lucide-react'; import { Plus, ChartGantt } from 'lucide-react';
import { useFloatingChat } from '@/context/FloatingChatContext';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart'; import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog'; import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog'; import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog';
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
export const Route = createFileRoute('/timeline')({ export const Route = createFileRoute('/timeline')({
component: TimelinePage, component: TimelinePage,
@@ -15,6 +17,14 @@ function TimelinePage() {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null); const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null);
// AI section
const timelineRef = useRef<HTMLDivElement>(null);
const { registerSection, unregisterSection } = useFloatingChat();
useEffect(() => {
registerSection({ id: 'timeline-chart', label: 'Timeline', ref: timelineRef });
return () => unregisterSection('timeline-chart');
}, [registerSection, unregisterSection]);
const { data: checkpoints } = trpc.checkpoints.list.useQuery({}); const { data: checkpoints } = trpc.checkpoints.list.useQuery({});
const { data: projectsList } = trpc.projects.listAll.useQuery(); const { data: projectsList } = trpc.projects.listAll.useQuery();
const utils = trpc.useUtils(); const utils = trpc.useUtils();
@@ -70,7 +80,7 @@ function TimelinePage() {
}, [ganttCheckpoints]); }, [ganttCheckpoints]);
return ( return (
<div className="flex flex-col gap-6 p-6 pe-8 w-full"> <div ref={timelineRef} data-ai-section="timeline-chart" 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>
@@ -98,9 +108,17 @@ function TimelinePage() {
{/* Gantt Chart */} {/* Gantt Chart */}
{ganttCheckpoints.length === 0 ? ( {ganttCheckpoints.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-12 border rounded-md bg-muted/30"> <Empty>
No checkpoints yet. Click "+ Add" to create your first milestone. <EmptyHeader>
</div> <EmptyMedia variant="icon">
<ChartGantt />
</EmptyMedia>
<EmptyTitle>No milestones yet</EmptyTitle>
<EmptyDescription>
Click "+ Add" to create your first project checkpoint.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : ( ) : (
<div className="border rounded-md p-4 bg-card"> <div className="border rounded-md p-4 bg-card">
<GanttChart <GanttChart

View File

@@ -7,7 +7,6 @@ export default defineConfig({
// Externalize native Node modules — they're rebuilt by electron-forge // Externalize native Node modules — they're rebuilt by electron-forge
external: [ external: [
'better-sqlite3', 'better-sqlite3',
'keytar',
'@github/copilot-sdk', '@github/copilot-sdk',
'@github/copilot', '@github/copilot',
// LangChain — externalize to avoid bundling Node.js-specific code // LangChain — externalize to avoid bundling Node.js-specific code