7 Commits

Author SHA1 Message Date
e8c8ddd48d feat: add CI/CD pipeline with cross-compilation support 2026-03-04 09:00:36 +01:00
Roberto Musso
d82738e7ea feat(AppShell): add SidebarTrigger component for improved sidebar accessibility 2026-03-01 14:37:08 +01:00
Roberto Musso
e005872ba0 feat(AIChatPanel): add aria-labels for accessibility; clean up unused lines
feat(AppShell): improve token storage message styling for better visibility
feat(ProjectDetail): implement skeleton loading state for project details
fix(ProjectSidebar): refactor variable declaration for clarity
style(PriorityBadge): enhance priority badge colors for better contrast
refactor(TaskRow): simplify className handling with utility function
fix(TasksPage): replace loader icon with clock icon for in-progress tasks
feat(TimelinePage): enhance empty state with descriptive messaging and icon
2026-03-01 10:40:22 +01:00
Roberto Musso
d3e82a3ebb feat(AIChatPanel): implement dynamic chat message font size and enhance user message scrolling behavior 2026-03-01 00:21:57 +01:00
Roberto Musso
af8cbc1c96 fix: update default userName from 'Roberto' to 'there' in store and AIChatPanel 2026-02-28 23:53:25 +01:00
Roberto Musso
ee6467a7ac feat: add knip configuration file and integrate knip for linting; update package.json and package-lock.json for new dependencies; refactor various interfaces to remove export modifiers; delete unused hover-card component 2026-02-28 23:44:10 +01:00
Roberto Musso
cdf9a8bf18 feat(FloatingChat): refactor chat width handling to be dynamic; enhance message panel positioning and styling with glass surface effects 2026-02-28 23:30:47 +01:00
24 changed files with 1093 additions and 218 deletions

View File

@@ -82,12 +82,9 @@ Tool-calling strategy differs by provider: OpenAI/Anthropic use LangChain `bindT
**Provider factory** (`llm.ts`): `gpt-4o-mini` (OpenAI), `claude-sonnet-4-20250514` (Anthropic), or ChatCopilot wrapper — all with `temperature: 0.3` and streaming enabled. **Provider factory** (`llm.ts`): `gpt-4o-mini` (OpenAI), `claude-sonnet-4-20250514` (Anthropic), or ChatCopilot wrapper — all with `temperature: 0.3` and streaming enabled.
**Token storage** (`token.ts`) — three-tier fallback: **Token storage** (`token.ts`) — two-tier fallback:
1. keytar (OS keychain) — preferred, encrypted per-user 1. electron-store + `safeStorage` — encrypted at rest (preferred)
2. electron-store + `safeStorage` — encrypted at rest 2. Plain electron-store — last resort (e.g. WSL with no keyring)
3. Plain electron-store — WSL fallback
Keytar service name is `'adiuva'`. Once keytar fails, `keytarFailed` flag skips it for the session.
**AI approval pattern**: Tasks and checkpoints have `isAiSuggested` (bool) and `isApproved` (bool) columns. AI-suggested items appear in the UI pending user approval before being treated as real records. **AI approval pattern**: Tasks and checkpoints have `isAiSuggested` (bool) and `isApproved` (bool) columns. AI-suggested items appear in the UI pending user approval before being treated as real records.

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

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

View File

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

22
knip.json Normal file
View File

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

476
package-lock.json generated
View File

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

View File

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

View File

@@ -33,13 +33,13 @@ let currentSender: Electron.WebContents | undefined;
// Types // Types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface OrchestrateInput { interface OrchestrateInput {
message: string; message: string;
context: { type: 'global' | 'project'; projectId?: string; uiContext?: string }; context: { type: 'global' | 'project'; projectId?: string; uiContext?: string };
sender?: Electron.WebContents; sender?: Electron.WebContents;
} }
export interface OrchestrateResult { interface OrchestrateResult {
response: string; response: string;
error?: string; error?: string;
} }

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,9 @@ import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { GradualBlur } from '@/components/ui/gradual-blur'; 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 = [ const SUGGESTION_CHIPS = [
{ icon: ListTodo, label: "What's on my plate today?" }, { icon: ListTodo, label: "What's on my plate today?" },
{ icon: TrendingUp, label: 'Summarize this week' }, { icon: TrendingUp, label: 'Summarize this week' },
@@ -78,17 +81,45 @@ export function AIChatPanel({
const messagesContainerRef = useRef<HTMLDivElement | null>(null); 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 briefMutation = trpc.ai.dailyBrief.useMutation();
const scrollToBottom = useCallback(() => { // When the user message appears in the list, set the placeholder and scroll it to the top
const el = messagesContainerRef.current;
if (el) el.scrollTo({ top: el.scrollHeight });
}, []);
// Auto-scroll when messages change or streaming content updates
useEffect(() => { useEffect(() => {
scrollToBottom(); if (!pendingScrollRef.current) return;
}, [messages, streamingContent, scrollToBottom]); 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 // Auto-fire daily brief on home page
useEffect(() => { useEffect(() => {
@@ -125,6 +156,7 @@ export function AIChatPanel({
const handleSend = useCallback(() => { const handleSend = useCallback(() => {
if (briefLoading) return; if (briefLoading) return;
pendingScrollRef.current = true;
chatHandleSend(); chatHandleSend();
}, [briefLoading, chatHandleSend]); }, [briefLoading, chatHandleSend]);
@@ -135,7 +167,6 @@ export function AIChatPanel({
} }
}; };
const hasMessages = messages.length > 0 || isStreaming; const hasMessages = messages.length > 0 || isStreaming;
// Derived values for home page // Derived values for home page
@@ -162,12 +193,14 @@ export function AIChatPanel({
<div className="flex-1" /> <div className="flex-1" />
<button <button
onClick={() => setBriefExpanded((v) => !v)} 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" 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} />} {briefExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button> </button>
<button <button
onClick={() => setBriefDismissed(true)} 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" 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} /> <X size={14} />
@@ -177,7 +210,7 @@ export function AIChatPanel({
{!briefExpanded && ( {!briefExpanded && (
<div className="px-4 pb-3 -mt-1"> <div className="px-4 pb-3 -mt-1">
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
{dailyBrief.replace(/[#*_~`>\-]/g, '').slice(0, 120)}... {dailyBrief.replace(/[#*_~`>-]/g, '').slice(0, 120)}...
</p> </p>
</div> </div>
)} )}
@@ -324,12 +357,18 @@ export function AIChatPanel({
<div className="mx-auto w-full max-w-6xl px-6 pt-8 pb-32"> <div className="mx-auto w-full max-w-6xl px-6 pt-8 pb-32">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Chat messages */} {/* Chat messages */}
{messages.map((msg) => { {messages.map((msg, idx) => {
const isLastMsg = idx === messages.length - 1;
if (msg.role === 'user') { if (msg.role === 'user') {
return ( return (
<div key={msg.id} className="flex justify-end"> <div
key={msg.id}
ref={isLastMsg ? lastUserMsgRef : undefined}
className="flex justify-end"
>
<div className="ml-auto max-w-[75%] rounded-2xl bg-muted px-4 py-2"> <div className="ml-auto max-w-[75%] rounded-2xl bg-muted px-4 py-2">
<ChatMarkdown content={msg.content} /> <ChatMarkdown content={msg.content} fontSize={CHAT_FONT} />
</div> </div>
</div> </div>
); );
@@ -338,7 +377,7 @@ export function AIChatPanel({
if (msg.error) { if (msg.error) {
return ( return (
<div key={msg.id} className="mr-auto max-w-[75%]"> <div key={msg.id} className="mr-auto max-w-[75%]">
<p className="text-sm text-destructive whitespace-pre-wrap"> <p style={{ fontSize: CHAT_FONT }} className="text-destructive whitespace-pre-wrap">
{msg.content} {msg.content}
</p> </p>
</div> </div>
@@ -349,10 +388,10 @@ export function AIChatPanel({
<div key={msg.id} className="mr-auto max-w-[75%]"> <div key={msg.id} className="mr-auto max-w-[75%]">
<div className="flex items-center gap-1.5 mb-1"> <div className="flex items-center gap-1.5 mb-1">
<Sparkles size={16} className="text-foreground" /> <Sparkles size={16} className="text-foreground" />
<span className="text-sm font-semibold">Adiuva</span> <span style={{ fontSize: CHAT_FONT }} className="font-semibold">Adiuva</span>
</div> </div>
<div className="pl-[22px]"> <div className="pl-[22px]">
<ChatMarkdown content={msg.content} /> <ChatMarkdown content={msg.content} fontSize={CHAT_FONT} />
</div> </div>
</div> </div>
); );
@@ -360,14 +399,14 @@ export function AIChatPanel({
{/* Streaming AI response */} {/* Streaming AI response */}
{isStreaming && ( {isStreaming && (
<div className="mr-auto max-w-[75%]"> <div ref={setStreamingEl} className="mr-auto max-w-[75%]">
<div className="flex items-center gap-1.5 mb-1"> <div className="flex items-center gap-1.5 mb-1">
<Sparkles size={16} className="text-foreground" /> <Sparkles size={16} className="text-foreground" />
<span className="text-sm font-semibold">Adiuva</span> <span style={{ fontSize: CHAT_FONT }} className="font-semibold">Adiuva</span>
</div> </div>
{streamingContent ? ( {streamingContent ? (
<div className="pl-[22px]"> <div className="pl-[22px]">
<ChatMarkdown content={streamingContent} /> <ChatMarkdown content={streamingContent} fontSize={CHAT_FONT} />
</div> </div>
) : ( ) : (
<div className="space-y-2 pl-[22px]"> <div className="space-y-2 pl-[22px]">
@@ -377,6 +416,18 @@ export function AIChatPanel({
)} )}
</div> </div>
)} )}
{/* Placeholder: fills viewport after user message, shrinks as AI responds */}
{placeholderHeight !== null && (
<div
aria-hidden
style={{
height: placeholderHeight,
transition: 'height 180ms ease-out',
flexShrink: 0,
}}
/>
)}
</div> </div>
</div> </div>
)} )}
@@ -428,6 +479,7 @@ function ChatInput({
onChange={(e) => onInputChange(e.target.value)} onChange={(e) => onInputChange(e.target.value)}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
placeholder="Ask me anything..." placeholder="Ask me anything..."
aria-label="Chat message"
rows={1} rows={1}
className="flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground outline-none max-h-[7.5rem] overflow-y-auto" className="flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground outline-none max-h-[7.5rem] overflow-y-auto"
style={{ fieldSizing: 'content' } as React.CSSProperties} style={{ fieldSizing: 'content' } as React.CSSProperties}
@@ -435,6 +487,7 @@ function ChatInput({
<button <button
onClick={onSend} onClick={onSend}
disabled={!input.trim() || isStreaming} disabled={!input.trim() || isStreaming}
aria-label="Send message"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100" className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100"
> >
<ArrowUp size={16} /> <ArrowUp size={16} />
@@ -446,9 +499,12 @@ function ChatInput({
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */ /* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
export function ChatMarkdown({ content, size = 'sm' }: { content: string; size?: 'sm' | 'lg' }) { export function ChatMarkdown({ content, size = 'sm', fontSize }: { content: string; size?: 'sm' | 'lg'; fontSize?: string }) {
return ( return (
<div className={`prose dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${size === 'lg' ? 'prose-base' : 'prose-sm'}`}> <div
className={`prose dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${size === 'lg' ? 'prose-base' : 'prose-sm'}`}
style={fontSize ? { fontSize } : undefined}
>
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={{

View File

@@ -6,7 +6,7 @@ import { X, ArrowUp } from 'lucide-react';
import { import {
useFloatingChat, useFloatingChat,
computeDualAnchor, computeDualAnchor,
CHAT_WIDTH, getChatWidth,
CHAT_HEIGHT, CHAT_HEIGHT,
PADDING, PADDING,
} from '@/context/FloatingChatContext'; } from '@/context/FloatingChatContext';
@@ -66,7 +66,7 @@ function FloatingChatInner() {
if (route === 'project' && state.projectId) { if (route === 'project' && state.projectId) {
// Navigate to the project page (stay on same project) // Navigate to the project page (stay on same project)
// Project sections re-register on mount and pendingSection will auto-open // Project sections re-register on mount and pendingSection will auto-open
void navigate({ to: '/projects/$projectId', params: { projectId: state.projectId } }); void navigate({ to: '/projects', search: { projectId: state.projectId } });
} else if (route.startsWith('/')) { } else if (route.startsWith('/')) {
void navigate({ to: route }); void navigate({ to: route });
} }
@@ -154,7 +154,7 @@ function FloatingChatInner() {
if (el) { if (el) {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
if (rect.right > window.innerWidth || rect.bottom > window.innerHeight) { 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`; el.style.top = `${Math.max(PADDING, Math.min(state.position.y, window.innerHeight - CHAT_HEIGHT - PADDING))}px`;
} }
} }
@@ -241,6 +241,10 @@ function FloatingChatInner() {
const hasMessages = messages.length > 0 || isStreaming; 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 ( return (
<AnimatePresence> <AnimatePresence>
{state.isOpen && ( {state.isOpen && (
@@ -260,30 +264,37 @@ function FloatingChatInner() {
width: state.position.width, width: state.position.width,
zIndex: 9999, 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> <AnimatePresence>
{hasMessages && ( {hasMessages && (
<motion.div <motion.div
key="messages-panel" key="messages-panel"
initial={{ opacity: 0, height: 0, scale: 0.97 }} initial={{ opacity: 0, scale: 0.97, y: expandUp ? 8 : -8 }}
animate={{ opacity: 1, height: 'auto', scale: 1 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, height: 0, scale: 0.97 }} exit={{ opacity: 0, scale: 0.97, y: expandUp ? 8 : -8 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }} 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 <div
ref={scrollRef} 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"> <div className="flex flex-col gap-2.5 p-3">
{messages.map((msg) => { {messages.map((msg) => {
if (msg.role === 'user') { if (msg.role === 'user') {
return ( return (
<div key={msg.id} className="flex justify-end"> <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"> <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"> <p className="text-xs whitespace-pre-wrap leading-relaxed text-foreground">
{msg.content} {msg.content}
</p> </p>
</div> </div>
@@ -294,7 +305,7 @@ function FloatingChatInner() {
if (msg.error) { if (msg.error) {
return ( return (
<div key={msg.id} className="flex justify-start"> <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"> <p className="text-xs text-destructive whitespace-pre-wrap leading-relaxed">
{msg.content} {msg.content}
</p> </p>
@@ -305,8 +316,8 @@ function FloatingChatInner() {
return ( return (
<div key={msg.id} className="flex justify-start"> <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="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2">
<div className="text-xs"> <div className="text-xs text-foreground">
<ChatMarkdown content={msg.content} /> <ChatMarkdown content={msg.content} />
</div> </div>
</div> </div>
@@ -317,9 +328,9 @@ function FloatingChatInner() {
{/* Streaming */} {/* Streaming */}
{isStreaming && ( {isStreaming && (
<div className="flex justify-start"> <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 ? ( {streamingContent ? (
<div className="text-xs"> <div className="text-xs text-foreground">
<ChatMarkdown content={streamingContent} /> <ChatMarkdown content={streamingContent} />
</div> </div>
) : ( ) : (
@@ -338,7 +349,7 @@ function FloatingChatInner() {
</AnimatePresence> </AnimatePresence>
{/* ---- Floating input bar ---- */} {/* ---- 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 */} {/* Close button */}
<button <button
onClick={close} onClick={close}

View File

@@ -29,14 +29,13 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
SidebarProvider, SidebarProvider,
SidebarTrigger,
useSidebar, useSidebar,
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuPortal, DropdownMenuPortal,
DropdownMenuSub, DropdownMenuSub,
DropdownMenuTrigger, DropdownMenuTrigger,
@@ -131,6 +130,9 @@ function AppShellInner({ children }: AppShellProps) {
/> />
) : ( ) : (
<div className="relative flex flex-col h-full"> <div className="relative flex flex-col h-full">
<header className="flex items-center gap-2 p-2 md:hidden">
<SidebarTrigger />
</header>
{children} {children}
</div> </div>
)} )}
@@ -163,13 +165,13 @@ function AppShellInner({ children }: AppShellProps) {
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Your token is stored securely in the OS keychain. Your token is stored securely in the OS keychain.
{hasTokenQuery.data === true && ( {hasTokenQuery.data === true && (
<span className="text-green-600 ml-1">A token is currently stored.</span> <span className="text-green-600 dark:text-green-400 ml-1">A token is currently stored.</span>
)} )}
</p> </p>
</div> </div>
<DialogFooter> <DialogFooter>
{saved && ( {saved && (
<span className="flex items-center gap-1 text-sm text-green-600 mr-auto"> <span className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400 mr-auto">
<Check size={14} /> <Check size={14} />
Saved Saved
</span> </span>

View File

@@ -4,6 +4,7 @@ import { format } from 'date-fns';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item'; import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
import { import {
Breadcrumb, Breadcrumb,
@@ -167,8 +168,17 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground"> <div className="p-6 flex flex-col gap-6">
Loading project... <div className="flex flex-col gap-1">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-56" />
</div>
<div className="grid grid-cols-3 gap-4">
<Skeleton className="h-20 rounded-lg" />
<Skeleton className="h-20 rounded-lg" />
<Skeleton className="h-20 rounded-lg" />
</div>
<Skeleton className="h-16 rounded-lg" />
</div> </div>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@ import {
createContext, createContext,
useContext, useContext,
useCallback, useCallback,
useEffect,
useState, useState,
useRef, useRef,
type ReactNode, type ReactNode,
@@ -11,7 +10,7 @@ import {
// ---------- Types ---------- // ---------- Types ----------
export interface AISection { interface AISection {
id: string; // e.g. "project-tasks", "tasks-list", "timeline-chart" id: string; // e.g. "project-tasks", "tasks-list", "timeline-chart"
label: string; // Human-readable, e.g. "Tasks", "Project Timeline" label: string; // Human-readable, e.g. "Tasks", "Project Timeline"
ref: RefObject<HTMLElement | null>; ref: RefObject<HTMLElement | null>;
@@ -19,7 +18,7 @@ export interface AISection {
anchorMode?: 'top-right' | 'right-margin'; // default: 'top-right' anchorMode?: 'top-right' | 'right-margin'; // default: 'top-right'
} }
export interface SectionOpenOpts { interface SectionOpenOpts {
clickY?: number; // For right-margin mode: Y-coordinate of the double-click clickY?: number; // For right-margin mode: Y-coordinate of the double-click
} }
@@ -52,15 +51,20 @@ interface FloatingChatContextValue {
// ---------- Constants ---------- // ---------- 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 CHAT_HEIGHT = 420;
export const PADDING = 16; export const PADDING = 16;
// ---------- Position computation ---------- // ---------- Position computation ----------
function clampPosition(x: number, y: number): { x: number; y: number } { function clampPosition(x: number, y: number): { x: number; y: number } {
const w = getChatWidth();
return { return {
x: Math.max(PADDING, Math.min(x, window.innerWidth - CHAT_WIDTH - PADDING)), x: Math.max(PADDING, Math.min(x, window.innerWidth - w - PADDING)),
y: Math.max(PADDING, Math.min(y, window.innerHeight - CHAT_HEIGHT - PADDING)), y: Math.max(PADDING, Math.min(y, window.innerHeight - CHAT_HEIGHT - PADDING)),
}; };
} }
@@ -70,7 +74,8 @@ function computeAnchorPosition(
opts?: SectionOpenOpts, opts?: SectionOpenOpts,
): { x: number; y: number; width: number } { ): { x: number; y: number; width: number } {
const el = section.ref.current; const el = section.ref.current;
if (!el) return { x: PADDING, y: PADDING, width: CHAT_WIDTH }; const w = getChatWidth();
if (!el) return { x: PADDING, y: PADDING, width: w };
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const mode = section.anchorMode ?? 'top-right'; const mode = section.anchorMode ?? 'top-right';
@@ -80,14 +85,14 @@ function computeAnchorPosition(
const rawX = rect.right + PADDING; const rawX = rect.right + PADDING;
const rawY = opts?.clickY ?? rect.top + PADDING; const rawY = opts?.clickY ?? rect.top + PADDING;
const { x, y } = clampPosition(rawX, rawY); const { x, y } = clampPosition(rawX, rawY);
return { x, y, width: CHAT_WIDTH }; return { x, y, width: w };
} }
// Default: top-right of section // Default: top-right of section
const rawX = rect.right - CHAT_WIDTH - PADDING; const rawX = rect.right - w - PADDING;
const rawY = rect.top + PADDING; const rawY = rect.top + PADDING;
const { x, y } = clampPosition(rawX, rawY); const { x, y } = clampPosition(rawX, rawY);
return { x, y, width: CHAT_WIDTH }; return { x, y, width: w };
} }
/** /**
@@ -104,6 +109,7 @@ export function computeDualAnchor(
if (section.anchorMode === 'right-margin') return null; if (section.anchorMode === 'right-margin') return null;
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const w = getChatWidth();
// Fully off-screen — freeze // Fully off-screen — freeze
if (rect.bottom < 0 || rect.top > window.innerHeight) return null; if (rect.bottom < 0 || rect.top > window.innerHeight) return null;
@@ -111,27 +117,27 @@ export function computeDualAnchor(
// Primary anchor: top-right (when section top is visible) // Primary anchor: top-right (when section top is visible)
if (rect.top >= PADDING) { if (rect.top >= PADDING) {
const { x, y } = clampPosition( const { x, y } = clampPosition(
rect.right - CHAT_WIDTH - PADDING, rect.right - w - PADDING,
rect.top + PADDING, rect.top + PADDING,
); );
return { x, y, width: CHAT_WIDTH }; return { x, y, width: w };
} }
// Fallback anchor: bottom-right (when section top scrolled off) // Fallback anchor: bottom-right (when section top scrolled off)
if (rect.bottom > CHAT_HEIGHT) { if (rect.bottom > CHAT_HEIGHT) {
const { x, y } = clampPosition( const { x, y } = clampPosition(
rect.right - CHAT_WIDTH - PADDING, rect.right - w - PADDING,
rect.bottom - CHAT_HEIGHT - PADDING, rect.bottom - CHAT_HEIGHT - PADDING,
); );
return { x, y, width: CHAT_WIDTH }; return { x, y, width: w };
} }
// Section visible but too small for fallback — clamp to top // Section visible but too small for fallback — clamp to top
const { x, y } = clampPosition( const { x, y } = clampPosition(
rect.right - CHAT_WIDTH - PADDING, rect.right - w - PADDING,
PADDING, PADDING,
); );
return { x, y, width: CHAT_WIDTH }; return { x, y, width: w };
} }
// ---------- Context ---------- // ---------- Context ----------
@@ -145,15 +151,6 @@ export function useFloatingChat(): FloatingChatContextValue {
return ctx; 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 ---------- // ---------- Provider ----------
@@ -163,7 +160,7 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<FloatingChatState>({ const [state, setState] = useState<FloatingChatState>({
isOpen: false, isOpen: false,
activeSectionId: null, activeSectionId: null,
position: { x: 0, y: 0, width: CHAT_WIDTH }, position: { x: 0, y: 0, width: getChatWidth() },
morphTargetId: null, morphTargetId: null,
}); });

View File

@@ -184,6 +184,78 @@ body {
overflow: hidden; overflow: hidden;
} }
/* ---- Glass Surface (ReactBits-style) ---- */
/*
* Gradient border via padding-box/border-box background split —
* most reliable technique in Chromium/Electron; no pseudo-element mask needed.
*/
.glass-surface {
border: 1px solid transparent;
background:
/* glass fill — clips to padding-box (inside the border) */
rgba(255, 255, 255, 0.55) padding-box,
/* gradient border — clips to border-box (the 1px border strip) */
linear-gradient(
145deg,
rgba(255, 255, 255, 0.90) 0%,
rgba(200, 195, 205, 0.40) 40%,
rgba(200, 195, 205, 0.20) 100%
) border-box;
backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%);
box-shadow:
0 4px 48px rgba(0, 0, 0, 0.10),
0 1px 2px rgba(0, 0, 0, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.80);
}
.dark .glass-surface {
background:
rgba(255, 255, 255, 0.05) padding-box,
linear-gradient(
145deg,
rgba(255, 255, 255, 0.18) 0%,
rgba(255, 255, 255, 0.04) 40%,
rgba(255, 255, 255, 0.08) 100%
) border-box;
box-shadow:
0 4px 48px rgba(0, 0, 0, 0.50),
0 1px 2px rgba(0, 0, 0, 0.20),
inset 0 1px 0 rgba(255, 255, 255, 0.10);
}
/* Subtle variant — same gradient border, much more transparent fill */
.glass-surface-subtle {
border: 1px solid transparent;
background:
rgba(255, 255, 255, 0.20) padding-box,
linear-gradient(
145deg,
rgba(255, 255, 255, 0.70) 0%,
rgba(200, 195, 205, 0.25) 40%,
rgba(200, 195, 205, 0.10) 100%
) border-box;
backdrop-filter: blur(16px) saturate(160%);
-webkit-backdrop-filter: blur(16px) saturate(160%);
box-shadow:
0 2px 12px rgba(0, 0, 0, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.60);
}
.dark .glass-surface-subtle {
background:
rgba(255, 255, 255, 0.03) padding-box,
linear-gradient(
145deg,
rgba(255, 255, 255, 0.12) 0%,
rgba(255, 255, 255, 0.02) 40%,
rgba(255, 255, 255, 0.05) 100%
) border-box;
box-shadow:
0 2px 12px rgba(0, 0, 0, 0.30),
inset 0 1px 0 rgba(255, 255, 255, 0.07);
}
/* Crepe editor layout */ /* Crepe editor layout */
.milkdown-container { .milkdown-container {
display: flex; display: flex;

View File

@@ -1,7 +1,7 @@
import { useState, useCallback, useRef } from 'react'; import { useState, useCallback, useRef } from 'react';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
export interface ChatMessage { interface ChatMessage {
id: string; id: string;
role: 'user' | 'assistant'; role: 'user' | 'assistant';
content: string; content: string;
@@ -14,7 +14,7 @@ export interface ChatContext {
uiContext?: string; uiContext?: string;
} }
export interface UseAIChatReturn { interface UseAIChatReturn {
messages: ChatMessage[]; messages: ChatMessage[];
input: string; input: string;
setInput: (v: string) => void; setInput: (v: string) => void;
@@ -24,7 +24,7 @@ export interface UseAIChatReturn {
clearMessages: () => void; clearMessages: () => void;
} }
export interface UseAIChatOptions { interface UseAIChatOptions {
onSectionTag?: (sectionId: string) => void; onSectionTag?: (sectionId: string) => void;
} }
@@ -68,7 +68,7 @@ export function useAIChat(defaultContext: ChatContext, options?: UseAIChatOption
const sectionMatch = finalContent.match(/^\[SECTION:([\w-]+)\]\s*/); const sectionMatch = finalContent.match(/^\[SECTION:([\w-]+)\]\s*/);
if (sectionMatch) { if (sectionMatch) {
finalContent = finalContent.slice(sectionMatch[0].length); finalContent = finalContent.slice(sectionMatch[0].length);
options?.onSectionTag?.(sectionMatch[1]); options?.onSectionTag?.(sectionMatch[1]!);
} }
setMessages((prev) => [ setMessages((prev) => [

View File

@@ -4,7 +4,7 @@ import { useFloatingChat } from '@/context/FloatingChatContext';
import { import {
ClipboardCheck, ClipboardCheck,
ListTodo, ListTodo,
Loader2, Clock,
CheckCircle2, CheckCircle2,
Plus, Plus,
Search, Search,
@@ -147,7 +147,7 @@ function TasksPage() {
</Item> </Item>
<Item variant="muted" className="bg-sky-50 dark:bg-sky-950/30"> <Item variant="muted" className="bg-sky-50 dark:bg-sky-950/30">
<ItemMedia variant="icon"> <ItemMedia variant="icon">
<Loader2 /> <Clock />
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>{stats.inProgress}</ItemTitle> <ItemTitle>{stats.inProgress}</ItemTitle>

View File

@@ -1,12 +1,13 @@
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
import { useEffect, useRef, useState, useMemo } from 'react'; import { useEffect, useRef, useState, useMemo } from 'react';
import { Plus } from 'lucide-react'; import { Plus, ChartGantt } from 'lucide-react';
import { useFloatingChat } from '@/context/FloatingChatContext'; import { useFloatingChat } from '@/context/FloatingChatContext';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart'; import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog'; import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog'; import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog';
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
export const Route = createFileRoute('/timeline')({ export const Route = createFileRoute('/timeline')({
component: TimelinePage, component: TimelinePage,
@@ -107,9 +108,17 @@ function TimelinePage() {
{/* Gantt Chart */} {/* Gantt Chart */}
{ganttCheckpoints.length === 0 ? ( {ganttCheckpoints.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-12 border rounded-md bg-muted/30"> <Empty>
No checkpoints yet. Click "+ Add" to create your first milestone. <EmptyHeader>
</div> <EmptyMedia variant="icon">
<ChartGantt />
</EmptyMedia>
<EmptyTitle>No milestones yet</EmptyTitle>
<EmptyDescription>
Click "+ Add" to create your first project checkpoint.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : ( ) : (
<div className="border rounded-md p-4 bg-card"> <div className="border rounded-md p-4 bg-card">
<GanttChart <GanttChart

View File

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