Compare commits
10 Commits
c5e78311e6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e8c8ddd48d | |||
|
|
d82738e7ea | ||
|
|
e005872ba0 | ||
|
|
d3e82a3ebb | ||
|
|
af8cbc1c96 | ||
|
|
ee6467a7ac | ||
|
|
cdf9a8bf18 | ||
|
|
f767bb5175 | ||
|
|
444aa37be2 | ||
|
|
15051cfa7a |
@@ -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.
|
||||
|
||||
**Token storage** (`token.ts`) — three-tier fallback:
|
||||
1. keytar (OS keychain) — preferred, encrypted per-user
|
||||
2. electron-store + `safeStorage` — encrypted at rest
|
||||
3. Plain electron-store — WSL fallback
|
||||
|
||||
Keytar service name is `'adiuva'`. Once keytar fails, `keytarFailed` flag skips it for the session.
|
||||
**Token storage** (`token.ts`) — two-tier fallback:
|
||||
1. electron-store + `safeStorage` — encrypted at rest (preferred)
|
||||
2. Plain electron-store — last resort (e.g. WSL with no keyring)
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
124
.gitea/workflows/build.yaml
Normal file
124
.gitea/workflows/build.yaml
Normal 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
|
||||
@@ -959,7 +959,7 @@ const { state: floatingState } = useFloatingChat();
|
||||
|
||||
## Step 8a: Page Interactions — Project Detail
|
||||
|
||||
**Status**: [ ]
|
||||
**Status**: [x] (2026-02-28)
|
||||
**Prerequisites**: Steps 1-4 completed
|
||||
**Modifies**: `src/renderer/components/projects/ProjectDetail.tsx`
|
||||
|
||||
@@ -1022,7 +1022,7 @@ Repeat for all 4 sections.
|
||||
|
||||
## Step 8b: Page Interactions — Tasks Page
|
||||
|
||||
**Status**: [ ]
|
||||
**Status**: [x] (2026-02-28)
|
||||
**Prerequisites**: Steps 1-4 completed
|
||||
**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
|
||||
|
||||
**Status**: [ ]
|
||||
**Status**: [x] (2026-02-28)
|
||||
**Prerequisites**: Steps 1-4 completed
|
||||
**Modifies**: `src/renderer/routes/timeline.tsx`
|
||||
|
||||
@@ -1082,7 +1082,7 @@ Register 1 section:
|
||||
|
||||
## Step 8d: Page Interactions — Notes Page (Milkdown)
|
||||
|
||||
**Status**: [ ]
|
||||
**Status**: [x] (2026-02-28)
|
||||
**Prerequisites**: Steps 1-4 completed
|
||||
**Modifies**: `src/renderer/routes/notes.$noteId.tsx`
|
||||
|
||||
|
||||
222
forge.config.ts
222
forge.config.ts
@@ -7,12 +7,232 @@ import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-nati
|
||||
import { VitePlugin } from '@electron-forge/plugin-vite';
|
||||
import { FusesPlugin } from '@electron-forge/plugin-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 = {
|
||||
packagerConfig: {
|
||||
asar: true,
|
||||
asar: {
|
||||
unpack: '**/{*.node,*.dll,*.so,*.dylib}',
|
||||
},
|
||||
name: 'adiuva',
|
||||
},
|
||||
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: [
|
||||
new MakerSquirrel({}),
|
||||
new MakerZIP({}, ['darwin']),
|
||||
|
||||
22
knip.json
Normal file
22
knip.json
Normal 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
476
package-lock.json
generated
@@ -17,7 +17,6 @@
|
||||
"@langchain/langgraph": "^1.1.5",
|
||||
"@langchain/openai": "^1.2.9",
|
||||
"@milkdown/crepe": "^7.18.0",
|
||||
"@milkdown/kit": "^7.18.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.2.0",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
@@ -33,7 +32,6 @@
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"electron-store": "^8.2.0",
|
||||
"framer-motion": "^12.34.2",
|
||||
"keytar": "^7.9.0",
|
||||
"lucide-react": "^0.575.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
@@ -59,6 +57,7 @@
|
||||
"@tanstack/router-vite-plugin": "^1.161.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/electron-squirrel-startup": "^1.0.2",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
@@ -69,6 +68,7 @@
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"knip": "^5.85.0",
|
||||
"postcss": "^8.5.6",
|
||||
"shadcn": "^3.8.5",
|
||||
"tailwindcss": "^4.2.0",
|
||||
@@ -4930,6 +4930,306 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||
@@ -7934,9 +8234,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz",
|
||||
"integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==",
|
||||
"version": "25.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
|
||||
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -13488,6 +13788,16 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||
@@ -13758,6 +14068,22 @@
|
||||
"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": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
@@ -15680,17 +16006,6 @@
|
||||
"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": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -15711,6 +16026,74 @@
|
||||
"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": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.5.6.tgz",
|
||||
@@ -17814,12 +18197,6 @@
|
||||
"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": {
|
||||
"version": "0.2.1",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
||||
@@ -21397,6 +21806,19 @@
|
||||
"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": {
|
||||
"version": "2.8.7",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
|
||||
@@ -23646,6 +24068,16 @@
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"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": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make",
|
||||
"publish": "electron-forge publish",
|
||||
"lint": "eslint --ext .ts,.tsx ."
|
||||
"lint": "eslint --ext .ts,.tsx .",
|
||||
"knip": "knip"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "rmusso",
|
||||
"author": "roberto",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.11.1",
|
||||
@@ -28,6 +29,7 @@
|
||||
"@tanstack/router-vite-plugin": "^1.161.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/electron-squirrel-startup": "^1.0.2",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
@@ -38,6 +40,7 @@
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"knip": "^5.85.0",
|
||||
"postcss": "^8.5.6",
|
||||
"shadcn": "^3.8.5",
|
||||
"tailwindcss": "^4.2.0",
|
||||
@@ -53,7 +56,6 @@
|
||||
"@langchain/langgraph": "^1.1.5",
|
||||
"@langchain/openai": "^1.2.9",
|
||||
"@milkdown/crepe": "^7.18.0",
|
||||
"@milkdown/kit": "^7.18.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.2.0",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
@@ -69,7 +71,6 @@
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"electron-store": "^8.2.0",
|
||||
"framer-motion": "^12.34.2",
|
||||
"keytar": "^7.9.0",
|
||||
"lucide-react": "^0.575.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
|
||||
@@ -33,13 +33,13 @@ let currentSender: Electron.WebContents | undefined;
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface OrchestrateInput {
|
||||
interface OrchestrateInput {
|
||||
message: string;
|
||||
context: { type: 'global' | 'project'; projectId?: string; uiContext?: string };
|
||||
sender?: Electron.WebContents;
|
||||
}
|
||||
|
||||
export interface OrchestrateResult {
|
||||
interface OrchestrateResult {
|
||||
response: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export function getActiveProviderName(): string {
|
||||
}
|
||||
|
||||
/** Switch to a different registered provider. */
|
||||
export function setActiveProviderName(name: string): void {
|
||||
function setActiveProviderName(name: string): void {
|
||||
const provider = providers.get(name);
|
||||
if (!provider) throw new Error(`Unknown AI provider: ${name}`);
|
||||
activeProvider = provider;
|
||||
|
||||
@@ -2,27 +2,11 @@ import { safeStorage } from 'electron';
|
||||
import { getStore } from '../store';
|
||||
|
||||
/**
|
||||
* Token storage with three-tier fallback:
|
||||
* 1. OS keychain via keytar (best — encrypted, per-user)
|
||||
* 2. Electron safeStorage + electron-store (encrypted at rest)
|
||||
* 3. Plain electron-store (last resort — e.g. WSL with no keyring)
|
||||
* Token storage with two-tier fallback:
|
||||
* 1. Electron safeStorage + electron-store (encrypted at rest)
|
||||
* 2. 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 {
|
||||
try {
|
||||
return safeStorage.isEncryptionAvailable();
|
||||
@@ -31,8 +15,6 @@ function canUseSafeStorage(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
const SERVICE_NAME = 'adiuva';
|
||||
|
||||
// --- electron-store helpers (with optional safeStorage encryption) ---
|
||||
|
||||
function readFromStore(providerName: string): string | null {
|
||||
@@ -74,41 +56,16 @@ function removeFromStore(providerName: string): void {
|
||||
|
||||
/** Read a stored token for the given provider. */
|
||||
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);
|
||||
}
|
||||
|
||||
/** Store a token for the given provider. */
|
||||
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);
|
||||
}
|
||||
|
||||
/** Delete a stored token for the given provider. */
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
async function deleteToken(providerName: string): Promise<boolean> {
|
||||
removeFromStore(providerName);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type AnyRouter,
|
||||
} from '@trpc/server';
|
||||
|
||||
export const IPC_CHANNEL = 'trpc';
|
||||
const IPC_CHANNEL = 'trpc';
|
||||
|
||||
/** Context passed to every tRPC procedure via the IPC bridge. */
|
||||
export type TRPCContext = {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
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 remarkGfm from 'remark-gfm';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { GradualBlur } from '@/components/ui/gradual-blur';
|
||||
|
||||
/** Fluid font size for chat messages — scales with viewport width */
|
||||
const CHAT_FONT = 'clamp(1.125rem, 1.4vw, 1.375rem)';
|
||||
|
||||
const SUGGESTION_CHIPS = [
|
||||
{ icon: ListTodo, label: "What's on my plate today?" },
|
||||
@@ -16,6 +20,28 @@ const SUGGESTION_CHIPS = [
|
||||
{ icon: Lightbulb, label: 'Suggest next actions' },
|
||||
] 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 {
|
||||
onOpenSettings?: () => void;
|
||||
isHomePage?: boolean;
|
||||
@@ -50,19 +76,50 @@ export function AIChatPanel({
|
||||
const briefContentRef = useRef('');
|
||||
const hasFiredBrief = useRef(false);
|
||||
|
||||
const [briefExpanded, setBriefExpanded] = useState(false);
|
||||
const [briefDismissed, setBriefDismissed] = useState(false);
|
||||
|
||||
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// --- Scroll-to-user-message + shrinking placeholder ---
|
||||
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 scrollToBottom = useCallback(() => {
|
||||
const el = messagesContainerRef.current;
|
||||
if (el) el.scrollTo({ top: el.scrollHeight });
|
||||
}, []);
|
||||
|
||||
// Auto-scroll when messages change or streaming content updates
|
||||
// When the user message appears in the list, set the placeholder and scroll it to the top
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, streamingContent, scrollToBottom]);
|
||||
if (!pendingScrollRef.current) return;
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
if (!lastMsg || lastMsg.role !== 'user') return;
|
||||
|
||||
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(() => {
|
||||
if (!isStreaming || !streamingEl) return;
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -99,6 +156,7 @@ export function AIChatPanel({
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
if (briefLoading) return;
|
||||
pendingScrollRef.current = true;
|
||||
chatHandleSend();
|
||||
}, [briefLoading, chatHandleSend]);
|
||||
|
||||
@@ -109,7 +167,6 @@ export function AIChatPanel({
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const hasMessages = messages.length > 0 || isStreaming;
|
||||
|
||||
// Derived values for home page
|
||||
@@ -118,37 +175,131 @@ export function AIChatPanel({
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-0 flex flex-col bg-background">
|
||||
{/* Sticky brief toast — anchored at top when chatting */}
|
||||
<AnimatePresence>
|
||||
{isHomePage && hasMessages && dailyBrief && !briefDismissed && (
|
||||
<motion.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 */}
|
||||
<ScrollArea
|
||||
className="flex-1 min-h-0"
|
||||
viewportRef={messagesContainerRef}
|
||||
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'
|
||||
}
|
||||
>
|
||||
<div className="relative flex-1 min-h-0">
|
||||
{/* Gradual blur at the bottom of messages */}
|
||||
{hasMessages && (
|
||||
<GradualBlur
|
||||
position="bottom"
|
||||
strength={0.6}
|
||||
height="4rem"
|
||||
divCount={10}
|
||||
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 */}
|
||||
{isHomePage && !hasMessages && (
|
||||
<div className="mx-auto w-full max-w-3xl px-6 pt-8 pb-8">
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Greeting + brief grouped closely */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<h1 className="text-[30px] font-semibold" style={{ letterSpacing: '-1px' }}>
|
||||
✦ Hello, {userName}
|
||||
</h1>
|
||||
<Badge variant="secondary">
|
||||
{dueCount} Task{dueCount !== 1 ? 's' : ''} due
|
||||
</Badge>
|
||||
</div>
|
||||
<motion.div
|
||||
className="mx-auto w-full max-w-4xl px-8 pt-14 pb-8"
|
||||
variants={stagger}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
>
|
||||
<div className="flex flex-col" style={{ gap: 'clamp(2.5rem, 4vh, 4rem)' }}>
|
||||
{/* Greeting — editorial hero moment */}
|
||||
<motion.div variants={fadeUp} className="flex flex-col gap-1">
|
||||
<span
|
||||
className="font-light tracking-wide text-muted-foreground"
|
||||
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 */}
|
||||
<div>
|
||||
{/* Daily brief */}
|
||||
<motion.div variants={fadeUp} className="max-w-3xl">
|
||||
{hasTokenQuery.data === false ? (
|
||||
<div className="flex flex-col items-center gap-3 py-2">
|
||||
<KeyRound size={24} className="text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
<div className="flex flex-col items-start gap-3 py-2">
|
||||
<KeyRound size={20} className="text-muted-foreground" />
|
||||
<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.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={onOpenSettings}>
|
||||
@@ -156,23 +307,22 @@ export function AIChatPanel({
|
||||
</Button>
|
||||
</div>
|
||||
) : briefLoading && !dailyBrief ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-5 w-1/2" />
|
||||
<Skeleton className="h-5 w-2/3" />
|
||||
</div>
|
||||
) : 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.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Inline input + suggestion chips */}
|
||||
<div>
|
||||
{/* Input + suggestion links */}
|
||||
<motion.div variants={fadeUp} className="max-w-3xl">
|
||||
<ChatInput
|
||||
input={input}
|
||||
isStreaming={isStreaming || briefLoading}
|
||||
@@ -180,42 +330,45 @@ export function AIChatPanel({
|
||||
onKeyDown={handleKeyDown}
|
||||
onSend={handleSend}
|
||||
/>
|
||||
<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) => (
|
||||
<button
|
||||
key={chip.label}
|
||||
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)}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Home page with messages: brief stays, then messages */}
|
||||
{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">
|
||||
{/* Brief persists */}
|
||||
{dailyBrief && (
|
||||
<div className="mb-2">
|
||||
<ChatMarkdown content={dailyBrief} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat messages */}
|
||||
{messages.map((msg) => {
|
||||
{messages.map((msg, idx) => {
|
||||
const isLastMsg = idx === messages.length - 1;
|
||||
|
||||
if (msg.role === 'user') {
|
||||
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">
|
||||
<ChatMarkdown content={msg.content} />
|
||||
<ChatMarkdown content={msg.content} fontSize={CHAT_FONT} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -224,7 +377,7 @@ export function AIChatPanel({
|
||||
if (msg.error) {
|
||||
return (
|
||||
<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}
|
||||
</p>
|
||||
</div>
|
||||
@@ -235,10 +388,10 @@ export function AIChatPanel({
|
||||
<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>
|
||||
<span style={{ fontSize: CHAT_FONT }} className="font-semibold">Adiuva</span>
|
||||
</div>
|
||||
<div className="pl-[22px]">
|
||||
<ChatMarkdown content={msg.content} />
|
||||
<ChatMarkdown content={msg.content} fontSize={CHAT_FONT} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -246,14 +399,14 @@ export function AIChatPanel({
|
||||
|
||||
{/* Streaming AI response */}
|
||||
{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">
|
||||
<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>
|
||||
{streamingContent ? (
|
||||
<div className="pl-[22px]">
|
||||
<ChatMarkdown content={streamingContent} />
|
||||
<ChatMarkdown content={streamingContent} fontSize={CHAT_FONT} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 pl-[22px]">
|
||||
@@ -263,17 +416,29 @@ export function AIChatPanel({
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Non-home messages */}
|
||||
</ScrollArea>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Fixed input — pinned to the bottom (hidden on initial state) */}
|
||||
{/* Fixed input — pinned to the bottom, above the blur */}
|
||||
{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="absolute bottom-0 left-0 right-0 z-30 px-6 pb-5 pt-4 pointer-events-none">
|
||||
<div className="relative pointer-events-auto mx-auto max-w-3xl">
|
||||
<ChatInput
|
||||
input={input}
|
||||
@@ -314,6 +479,7 @@ function ChatInput({
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Ask me anything..."
|
||||
aria-label="Chat message"
|
||||
rows={1}
|
||||
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}
|
||||
@@ -321,6 +487,7 @@ function ChatInput({
|
||||
<button
|
||||
onClick={onSend}
|
||||
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"
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
@@ -332,9 +499,12 @@ function ChatInput({
|
||||
|
||||
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
|
||||
|
||||
export function ChatMarkdown({ content }: { content: string }) {
|
||||
export function ChatMarkdown({ content, size = 'sm', fontSize }: { content: string; size?: 'sm' | 'lg'; fontSize?: string }) {
|
||||
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
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useRouterState } from '@tanstack/react-router';
|
||||
import { useNavigate, useRouterState } from '@tanstack/react-router';
|
||||
import { X, ArrowUp } from 'lucide-react';
|
||||
import {
|
||||
useFloatingChat,
|
||||
CHAT_WIDTH,
|
||||
computeDualAnchor,
|
||||
getChatWidth,
|
||||
CHAT_HEIGHT,
|
||||
PADDING,
|
||||
} from '@/context/FloatingChatContext';
|
||||
@@ -14,9 +15,22 @@ 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 } = useFloatingChat();
|
||||
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);
|
||||
|
||||
@@ -33,6 +47,32 @@ function FloatingChatInner() {
|
||||
[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,
|
||||
@@ -41,7 +81,7 @@ function FloatingChatInner() {
|
||||
streamingContent,
|
||||
handleSend,
|
||||
clearMessages,
|
||||
} = useAIChat(chatContext);
|
||||
} = useAIChat(chatContext, { onSectionTag: handleSectionTag });
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -59,15 +99,15 @@ function FloatingChatInner() {
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [state.isOpen, close]);
|
||||
|
||||
// ---- Close on route change ----
|
||||
// ---- Close on route change (unless cross-page navigation pending) ----
|
||||
|
||||
useEffect(() => {
|
||||
const currentPath = routerState.location.pathname;
|
||||
if (prevPathRef.current !== currentPath && state.isOpen) {
|
||||
if (prevPathRef.current !== currentPath && state.isOpen && !state.pendingSection) {
|
||||
close();
|
||||
}
|
||||
prevPathRef.current = currentPath;
|
||||
}, [routerState.location.pathname, state.isOpen, close]);
|
||||
}, [routerState.location.pathname, state.isOpen, state.pendingSection, close]);
|
||||
|
||||
// ---- Clear messages on close ----
|
||||
|
||||
@@ -114,7 +154,7 @@ function FloatingChatInner() {
|
||||
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 - CHAT_WIDTH - PADDING))}px`;
|
||||
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`;
|
||||
}
|
||||
}
|
||||
@@ -123,6 +163,51 @@ function FloatingChatInner() {
|
||||
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);
|
||||
@@ -156,6 +241,10 @@ function FloatingChatInner() {
|
||||
|
||||
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 && (
|
||||
@@ -175,30 +264,37 @@ function FloatingChatInner() {
|
||||
width: state.position.width,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
className="flex flex-col gap-2"
|
||||
className="relative"
|
||||
>
|
||||
{/* ---- Messages panel (appears when chat has content) ---- */}
|
||||
{/* ---- Messages panel — floats above or below the input bar ---- */}
|
||||
<AnimatePresence>
|
||||
{hasMessages && (
|
||||
<motion.div
|
||||
key="messages-panel"
|
||||
initial={{ opacity: 0, height: 0, scale: 0.97 }}
|
||||
animate={{ opacity: 1, height: 'auto', scale: 1 }}
|
||||
exit={{ opacity: 0, height: 0, scale: 0.97 }}
|
||||
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 }}
|
||||
className="rounded-2xl"
|
||||
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"
|
||||
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="max-w-[80%] rounded-2xl rounded-br-md bg-accent text-primary-foreground px-3.5 py-2 shadow-sm">
|
||||
<p className="text-xs whitespace-pre-wrap leading-relaxed">
|
||||
<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>
|
||||
@@ -209,7 +305,7 @@ function FloatingChatInner() {
|
||||
if (msg.error) {
|
||||
return (
|
||||
<div key={msg.id} className="flex justify-start">
|
||||
<div className="max-w-[80%] rounded-2xl rounded-bl-md bg-destructive/10 px-3.5 py-2">
|
||||
<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>
|
||||
@@ -220,8 +316,8 @@ function FloatingChatInner() {
|
||||
|
||||
return (
|
||||
<div key={msg.id} className="flex justify-start">
|
||||
<div className="max-w-[80%] rounded-2xl rounded-bl-md bg-primary text-primary-foreground px-3.5 py-2">
|
||||
<div className="text-xs">
|
||||
<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>
|
||||
@@ -232,9 +328,9 @@ function FloatingChatInner() {
|
||||
{/* Streaming */}
|
||||
{isStreaming && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[80%] rounded-2xl rounded-bl-md bg-primary text-primary-foreground px-3.5 py-2">
|
||||
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2">
|
||||
{streamingContent ? (
|
||||
<div className="text-xs">
|
||||
<div className="text-xs text-foreground">
|
||||
<ChatMarkdown content={streamingContent} />
|
||||
</div>
|
||||
) : (
|
||||
@@ -253,7 +349,7 @@ function FloatingChatInner() {
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ---- Floating input bar ---- */}
|
||||
<div className="relative rounded-2xl bg-background/80 backdrop-blur-2xl shadow-[0_8px_60px_-12px_rgba(0,0,0,0.5)] border border-border/30 ring-1 ring-white/5 transition-shadow focus-within:shadow-[0_8px_60px_-8px_rgba(0,0,0,0.7)] focus-within:ring-ring/20">
|
||||
<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}
|
||||
|
||||
@@ -29,14 +29,13 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuTrigger,
|
||||
@@ -131,6 +130,9 @@ function AppShellInner({ children }: AppShellProps) {
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex flex-col h-full">
|
||||
<header className="flex items-center gap-2 p-2 md:hidden">
|
||||
<SidebarTrigger />
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
@@ -163,13 +165,13 @@ function AppShellInner({ children }: AppShellProps) {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your token is stored securely in the OS keychain.
|
||||
{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>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
{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} />
|
||||
Saved
|
||||
</span>
|
||||
|
||||
@@ -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 { format } from 'date-fns';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -16,6 +17,7 @@ import { KanbanBoard } from './KanbanBoard';
|
||||
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
|
||||
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
|
||||
import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog';
|
||||
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
|
||||
type ProjectDetailProps = {
|
||||
projectId: string;
|
||||
@@ -26,6 +28,26 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
||||
const [addCheckpointOpen, setAddCheckpointOpen] = useState(false);
|
||||
const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null);
|
||||
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 { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
|
||||
const { data: clientsList } = trpc.clients.list.useQuery();
|
||||
@@ -146,8 +168,17 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
||||
Loading project...
|
||||
<div className="p-6 flex flex-col gap-6">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -181,54 +212,57 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
||||
<h1 className="text-2xl font-semibold text-foreground">{project.name}</h1>
|
||||
</div>
|
||||
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Item variant="muted">
|
||||
<ItemMedia variant="icon">
|
||||
<FileText />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{notesCount}</ItemTitle>
|
||||
<ItemDescription>Notes</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
{/* Project Summary Section */}
|
||||
<div ref={summaryRef} data-ai-section="project-summary" className="flex flex-col gap-6">
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Item variant="muted">
|
||||
<ItemMedia variant="icon">
|
||||
<FileText />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{notesCount}</ItemTitle>
|
||||
<ItemDescription>Notes</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemMedia variant="icon">
|
||||
<CheckCircle2 />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{taskStats.done}/{taskStats.total}</ItemTitle>
|
||||
<ItemDescription>Tasks Complete</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
<Item variant="muted">
|
||||
<ItemMedia variant="icon">
|
||||
<CheckCircle2 />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{taskStats.done}/{taskStats.total}</ItemTitle>
|
||||
<ItemDescription>Tasks Complete</ItemDescription>
|
||||
</ItemContent>
|
||||
</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">
|
||||
<Milestone />
|
||||
<Sparkles />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{checkpointStats.approved}/{checkpointStats.total}</ItemTitle>
|
||||
<ItemDescription>Checkpoints</ItemDescription>
|
||||
<ItemTitle>AI Project Summary</ItemTitle>
|
||||
<ItemDescription>
|
||||
{project.aiSummary || 'AI summary will appear here'}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</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 */}
|
||||
<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">
|
||||
<h2 className="text-lg font-semibold">Project Timeline</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -306,7 +340,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<h2 className="text-lg font-semibold">Tasks</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -372,7 +406,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<h2 className="text-lg font-semibold">Notes</h2>
|
||||
<Button
|
||||
|
||||
@@ -322,7 +322,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
if (editCreatingClient && editNewClientName.trim()) {
|
||||
// Create a new client
|
||||
const result = await createClientMutation.mutateAsync({ name: editNewClientName.trim() });
|
||||
let parentId = result.id;
|
||||
const parentId = result.id;
|
||||
|
||||
if (editCreatingSubClient && editNewSubClientName.trim()) {
|
||||
// Also create a sub-client under the new client
|
||||
|
||||
@@ -4,21 +4,21 @@ export function PriorityBadge({ priority }: { priority: string | null }) {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
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" />
|
||||
High
|
||||
</span>
|
||||
);
|
||||
case 'medium':
|
||||
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" />
|
||||
Medium
|
||||
</span>
|
||||
);
|
||||
case 'low':
|
||||
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" />
|
||||
Low
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Fragment } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Calendar, User, Pencil, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
@@ -95,9 +97,13 @@ export function TaskRow({
|
||||
<ContextMenuTrigger asChild>
|
||||
<Wrapper
|
||||
{...wrapperProps}
|
||||
className={`flex flex-col gap-1.5 px-4 py-3 rounded-md border select-none transition-colors ${
|
||||
isDone ? 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900' : 'bg-card border-border'
|
||||
} ${onClick ? 'cursor-pointer hover:bg-accent/50' : 'cursor-default'}`}
|
||||
className={cn(
|
||||
'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)}
|
||||
>
|
||||
{/* Row 1: checkbox + title + description */}
|
||||
@@ -109,7 +115,7 @@ export function TaskRow({
|
||||
className="mt-0.5 shrink-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}
|
||||
</div>
|
||||
{task.description && (
|
||||
@@ -136,10 +142,12 @@ export function TaskRow({
|
||||
<Breadcrumb className="shrink-0">
|
||||
<BreadcrumbList>
|
||||
{breadcrumb.map((part, i) => (
|
||||
<BreadcrumbItem key={i}>
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <BreadcrumbSeparator />}
|
||||
<span className="text-xs">{part}</span>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbItem>
|
||||
<span className="text-xs">{part}</span>
|
||||
</BreadcrumbItem>
|
||||
</Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
109
src/renderer/components/ui/gradual-blur.tsx
Normal file
109
src/renderer/components/ui/gradual-blur.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -8,10 +8,12 @@ function ScrollArea({
|
||||
children,
|
||||
viewportRef,
|
||||
viewportClassName,
|
||||
scrollbarClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
|
||||
viewportRef?: React.Ref<HTMLDivElement>;
|
||||
viewportClassName?: string;
|
||||
scrollbarClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
@@ -29,7 +31,7 @@ function ScrollArea({
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollBar className={scrollbarClassName} />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
@@ -45,7 +47,7 @@ function ScrollBar({
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
"flex touch-none p-px transition-colors select-none z-50",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
@@ -11,11 +10,16 @@ import {
|
||||
|
||||
// ---------- Types ----------
|
||||
|
||||
export interface AISection {
|
||||
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 {
|
||||
@@ -24,6 +28,7 @@ interface FloatingChatState {
|
||||
position: { x: number; y: number; width: number };
|
||||
morphTargetId: string | null;
|
||||
projectId?: string;
|
||||
pendingSection?: { sectionId: string; clickY?: number }; // For cross-page navigation
|
||||
}
|
||||
|
||||
interface FloatingChatContextValue {
|
||||
@@ -36,40 +41,103 @@ interface FloatingChatContextValue {
|
||||
unregisterSection: (id: string) => void;
|
||||
|
||||
// Actions
|
||||
openAtSection: (sectionId: string) => void;
|
||||
moveToSection: (sectionId: string) => void;
|
||||
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 ----------
|
||||
|
||||
export const CHAT_WIDTH = 380;
|
||||
/** 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(
|
||||
sectionRef: RefObject<HTMLElement | null>,
|
||||
section: AISection,
|
||||
opts?: SectionOpenOpts,
|
||||
): { x: number; y: number; width: number } {
|
||||
const el = sectionRef.current;
|
||||
if (!el) return { x: PADDING, y: PADDING, width: CHAT_WIDTH };
|
||||
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';
|
||||
|
||||
// Anchor to top-right of section, offset inward
|
||||
let x = rect.right - CHAT_WIDTH - PADDING;
|
||||
let y = rect.top + PADDING;
|
||||
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 };
|
||||
}
|
||||
|
||||
// Edge-collision clamping
|
||||
x = Math.max(PADDING, Math.min(x, window.innerWidth - CHAT_WIDTH - PADDING));
|
||||
y = Math.max(
|
||||
// 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,
|
||||
Math.min(y, window.innerHeight - CHAT_HEIGHT - PADDING),
|
||||
);
|
||||
|
||||
return { x, y, width: CHAT_WIDTH };
|
||||
return { x, y, width: w };
|
||||
}
|
||||
|
||||
// ---------- Context ----------
|
||||
@@ -83,15 +151,6 @@ export function useFloatingChat(): FloatingChatContextValue {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// Convenience hook for pages to register a section
|
||||
export function useAISection(section: AISection): void {
|
||||
const { registerSection, unregisterSection } = useFloatingChat();
|
||||
|
||||
useEffect(() => {
|
||||
registerSection(section);
|
||||
return () => unregisterSection(section.id);
|
||||
}, [section.id, registerSection, unregisterSection]);
|
||||
}
|
||||
|
||||
// ---------- Provider ----------
|
||||
|
||||
@@ -101,13 +160,30 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<FloatingChatState>({
|
||||
isOpen: false,
|
||||
activeSectionId: null,
|
||||
position: { x: 0, y: 0, width: CHAT_WIDTH },
|
||||
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) => {
|
||||
@@ -115,11 +191,11 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
||||
setSections(new Map(sectionsRef.current));
|
||||
}, []);
|
||||
|
||||
const openAtSection = useCallback((sectionId: string) => {
|
||||
const openAtSection = useCallback((sectionId: string, opts?: SectionOpenOpts) => {
|
||||
const section = sectionsRef.current.get(sectionId);
|
||||
if (!section) return;
|
||||
|
||||
const position = computeAnchorPosition(section.ref);
|
||||
const position = computeAnchorPosition(section, opts);
|
||||
|
||||
setState({
|
||||
isOpen: true,
|
||||
@@ -130,11 +206,11 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const moveToSection = useCallback((sectionId: string) => {
|
||||
const moveToSection = useCallback((sectionId: string, opts?: SectionOpenOpts) => {
|
||||
const section = sectionsRef.current.get(sectionId);
|
||||
if (!section) return;
|
||||
|
||||
const position = computeAnchorPosition(section.ref);
|
||||
const position = computeAnchorPosition(section, opts);
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
@@ -157,6 +233,14 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
||||
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={{
|
||||
@@ -168,6 +252,8 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
||||
moveToSection,
|
||||
close,
|
||||
setMorphTarget,
|
||||
updatePosition,
|
||||
setPendingSection,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
@import '@fontsource/geist/300.css';
|
||||
@import '@fontsource/geist/400.css';
|
||||
@import '@fontsource/geist/500.css';
|
||||
@import '@fontsource/geist/600.css';
|
||||
@import '@fontsource/geist/700.css';
|
||||
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@@ -182,6 +184,78 @@ body {
|
||||
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 */
|
||||
.milkdown-container {
|
||||
display: flex;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
|
||||
export interface ChatMessage {
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
@@ -14,7 +14,7 @@ export interface ChatContext {
|
||||
uiContext?: string;
|
||||
}
|
||||
|
||||
export interface UseAIChatReturn {
|
||||
interface UseAIChatReturn {
|
||||
messages: ChatMessage[];
|
||||
input: string;
|
||||
setInput: (v: string) => void;
|
||||
@@ -24,7 +24,11 @@ export interface UseAIChatReturn {
|
||||
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 [input, setInput] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
@@ -58,7 +62,15 @@ export function useAIChat(defaultContext: ChatContext): UseAIChatReturn {
|
||||
|
||||
const unsubscribe = window.electronAI.onStreamChunk(({ token, 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) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: finalContent },
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
|
||||
|
||||
export function useDoubleClickAI(): void {
|
||||
const { openAtSection, state } = useFloatingChat();
|
||||
const { openAtSection, moveToSection, sections, state } = useFloatingChat();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
@@ -35,10 +35,20 @@ export function useDoubleClickAI(): void {
|
||||
// If popup is already open at THIS section, do nothing
|
||||
if (state.isOpen && state.activeSectionId === sectionId) return;
|
||||
|
||||
openAtSection(sectionId);
|
||||
// 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, state.isOpen, state.activeSectionId]);
|
||||
}, [openAtSection, moveToSection, sections, state.isOpen, state.activeSectionId]);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { MilkdownEditor } from '@/components/notes/MilkdownEditor';
|
||||
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
|
||||
export const Route = createFileRoute('/notes/$noteId')({
|
||||
component: NoteDetailPage,
|
||||
@@ -29,6 +30,21 @@ function NoteDetailPage() {
|
||||
const utils = trpc.useUtils();
|
||||
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 [isSaving, setIsSaving] = useState(false);
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -188,7 +204,7 @@ function NoteDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<MilkdownEditor
|
||||
key={noteId}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
import {
|
||||
ClipboardCheck,
|
||||
ListTodo,
|
||||
Loader2,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
Plus,
|
||||
Search,
|
||||
@@ -41,12 +41,17 @@ const ORDER_LABELS: Record<OrderBy, string> = {
|
||||
};
|
||||
|
||||
function TasksPage() {
|
||||
// Temporary test: register section for floating AI chat
|
||||
const testRef = useRef<HTMLDivElement>(null);
|
||||
// AI section refs
|
||||
const overviewRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const { state: floatingState, registerSection, unregisterSection } = useFloatingChat();
|
||||
useEffect(() => {
|
||||
registerSection({ id: 'test', label: 'Tasks', ref: testRef });
|
||||
return () => unregisterSection('test');
|
||||
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('');
|
||||
@@ -121,7 +126,7 @@ function TasksPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 w-full">
|
||||
{/* 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">
|
||||
<ItemMedia variant="icon">
|
||||
<ClipboardCheck />
|
||||
@@ -142,7 +147,7 @@ function TasksPage() {
|
||||
</Item>
|
||||
<Item variant="muted" className="bg-sky-50 dark:bg-sky-950/30">
|
||||
<ItemMedia variant="icon">
|
||||
<Loader2 />
|
||||
<Clock />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{stats.inProgress}</ItemTitle>
|
||||
@@ -160,50 +165,52 @@ function TasksPage() {
|
||||
</Item>
|
||||
</div>
|
||||
|
||||
{/* Search + Order By */}
|
||||
<div className="flex items-center gap-3">
|
||||
<InputGroup className="flex-1">
|
||||
<InputGroupAddon>
|
||||
<Search />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search tasks or projects..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
<Select value={orderBy} onValueChange={(v) => setOrderBy(v as OrderBy)}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Order by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.entries(ORDER_LABELS) as [OrderBy, string][]).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Task List Section */}
|
||||
<div ref={listRef} data-ai-section="tasks-list" className="flex flex-col gap-6">
|
||||
{/* Search + Order By */}
|
||||
<div className="flex items-center gap-3">
|
||||
<InputGroup className="flex-1">
|
||||
<InputGroupAddon>
|
||||
<Search />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search tasks or projects..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
<Select value={orderBy} onValueChange={(v) => setOrderBy(v as OrderBy)}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Order by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.entries(ORDER_LABELS) as [OrderBy, string][]).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter Tabs + New Task Button */}
|
||||
<div ref={testRef} data-ai-section="test" className="flex items-center justify-between">
|
||||
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
<TabsTrigger value="todo">To Do</TabsTrigger>
|
||||
<TabsTrigger value="in_progress">In Progress</TabsTrigger>
|
||||
<TabsTrigger value="done">Completed</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New Task
|
||||
</Button>
|
||||
</div>
|
||||
{/* Status Filter Tabs + New Task Button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
<TabsTrigger value="todo">To Do</TabsTrigger>
|
||||
<TabsTrigger value="in_progress">In Progress</TabsTrigger>
|
||||
<TabsTrigger value="done">Completed</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New Task
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Task List */}
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* Task List */}
|
||||
<div className="flex flex-col gap-1">
|
||||
{tasksList.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
@@ -233,6 +240,7 @@ function TasksPage() {
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NewTaskDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { Plus, ChartGantt } from 'lucide-react';
|
||||
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
|
||||
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
|
||||
import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog';
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
|
||||
|
||||
export const Route = createFileRoute('/timeline')({
|
||||
component: TimelinePage,
|
||||
@@ -15,6 +17,14 @@ function TimelinePage() {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
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: projectsList } = trpc.projects.listAll.useQuery();
|
||||
const utils = trpc.useUtils();
|
||||
@@ -70,7 +80,7 @@ function TimelinePage() {
|
||||
}, [ganttCheckpoints]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 w-full">
|
||||
<div ref={timelineRef} data-ai-section="timeline-chart" className="flex flex-col gap-6 p-6 w-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Timeline</h1>
|
||||
@@ -98,9 +108,17 @@ function TimelinePage() {
|
||||
|
||||
{/* Gantt Chart */}
|
||||
{ganttCheckpoints.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground text-sm py-12 border rounded-md bg-muted/30">
|
||||
No checkpoints yet. Click "+ Add" to create your first milestone.
|
||||
</div>
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<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">
|
||||
<GanttChart
|
||||
|
||||
@@ -7,7 +7,6 @@ export default defineConfig({
|
||||
// Externalize native Node modules — they're rebuilt by electron-forge
|
||||
external: [
|
||||
'better-sqlite3',
|
||||
'keytar',
|
||||
'@github/copilot-sdk',
|
||||
'@github/copilot',
|
||||
// LangChain — externalize to avoid bundling Node.js-specific code
|
||||
|
||||
Reference in New Issue
Block a user