feat: add task comments feature with CRUD operations
- Introduced a new `task_comments` table in the database schema. - Implemented task comments API endpoints for listing, creating, and deleting comments. - Enhanced the task detail dialog to display comments and allow users to add new comments. - Updated task row component to handle click events for viewing task details. - Added a theme provider to manage light/dark mode across the application. - Refactored Milkdown editor to use Crepe for improved markdown editing experience. - Updated global styles to accommodate new editor and theme changes. - Enhanced task filtering and sorting functionality in the tasks page.
This commit is contained in:
@@ -23,21 +23,19 @@ APPEND to progress.txt (never replace, always append):
|
|||||||
|
|
||||||
## USER REQUEST
|
## USER REQUEST
|
||||||
{
|
{
|
||||||
"id": "US-017",
|
"id": "US-018",
|
||||||
"title": "Fluid Curtain pull-down animation",
|
"title": "GitHub Copilot SDK setup and keytar token storage",
|
||||||
"description": "As a user, I want to pull down from the top of any view to slide the app panel off-screen and reveal the AI chat layer beneath.",
|
"description": "As a developer, I need the GitHub Copilot SDK initialized in the main process with secure OS keychain token storage so that AI features can authenticate.",
|
||||||
"acceptanceCriteria": [
|
"acceptanceCriteria": [
|
||||||
"framer-motion useMotionValue + useSpring (stiffness: 300, damping: 30) controls a 'y' CSS transform on the main app panel wrapper",
|
"keytar installed and imported in main process only (not renderer)",
|
||||||
"Trigger 1: wheel event listener at document level — when the current route's scroll position is at 0 and deltaY < 0 (overscroll up), animate panel y from 0 to viewport height",
|
"ai.setToken tRPC mutation accepts { token: string } and stores it via keytar.setPassword('adiuva', 'copilot-token', token)",
|
||||||
"Trigger 2: Cmd/Ctrl+K keyboard shortcut toggles curtain open (y = viewport height) and closed (y = 0)",
|
"ai.hasToken tRPC query returns a boolean indicating whether a token is stored",
|
||||||
"AI chat view is rendered as a fixed full-screen layer behind the sliding panel and becomes fully visible when panel slides down",
|
"On app start, main process reads the token from keychain and initializes the GitHub Copilot SDK client",
|
||||||
"App panel remains mounted during animation (no unmount/remount, no state loss)",
|
"Settings dialog uses shadcn/ui Dialog (DialogTrigger as a SidebarMenuButton with Settings/gear icon in the sidebar footer); dialog content uses shadcn/ui Input for token paste + shadcn/ui Button to save via ai.setToken",
|
||||||
"Returning from chat: wheel event with deltaY > 0 at chat-bottom OR Cmd/Ctrl+K slides panel back to y = 0",
|
"If no token is stored, AI-dependent features display a prompt using shadcn/ui Card with a shadcn/ui Button linking to the Settings dialog instead of throwing an error",
|
||||||
"Right-edge vertical 'keep scrolling for AI' label with chevron-down is visible in every section (non-interactive, visual hint only)",
|
"Typecheck passes"
|
||||||
"Typecheck passes",
|
|
||||||
"Verify in browser using dev-browser skill"
|
|
||||||
],
|
],
|
||||||
"priority": 17,
|
"priority": 18,
|
||||||
"passes": false,
|
"passes": false,
|
||||||
"notes": ""
|
"notes": ""
|
||||||
}
|
}
|
||||||
47
package-lock.json
generated
47
package-lock.json
generated
@@ -11,9 +11,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/geist": "^5.2.8",
|
"@fontsource/geist": "^5.2.8",
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
|
"@milkdown/crepe": "^7.18.0",
|
||||||
"@milkdown/kit": "^7.18.0",
|
"@milkdown/kit": "^7.18.0",
|
||||||
"@milkdown/react": "^7.18.0",
|
|
||||||
"@milkdown/theme-nord": "^7.18.0",
|
|
||||||
"@tailwindcss/vite": "^4.2.0",
|
"@tailwindcss/vite": "^4.2.0",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tanstack/react-router": "^1.161.1",
|
"@tanstack/react-router": "^1.161.1",
|
||||||
@@ -28,6 +27,7 @@
|
|||||||
"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",
|
||||||
@@ -4156,32 +4156,6 @@
|
|||||||
"prosemirror-view": "^1.41.3"
|
"prosemirror-view": "^1.41.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@milkdown/react": {
|
|
||||||
"version": "7.18.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@milkdown/react/-/react-7.18.0.tgz",
|
|
||||||
"integrity": "sha512-hk7CN6YqhazUBOdY0Iyh3RjvRyjsl2vBsJyf54ua38hxmaAD13KbTnEWZs30OnryoP6cv9z74bHPMIc2UnSVIQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@milkdown/crepe": "7.18.0",
|
|
||||||
"@milkdown/kit": "7.18.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "*",
|
|
||||||
"react-dom": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@milkdown/theme-nord": {
|
|
||||||
"version": "7.18.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@milkdown/theme-nord/-/theme-nord-7.18.0.tgz",
|
|
||||||
"integrity": "sha512-3t5Fb0Zwsmf2VDkhZm6U5C6/CWiU7UN+Z+/tpbOC1Stsid4CJ3y7j7m/3oRZlQyUEI07dHvAAOHqzyGoYl0FZw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@milkdown/core": "7.18.0",
|
|
||||||
"@milkdown/ctx": "7.18.0",
|
|
||||||
"@milkdown/prose": "7.18.0",
|
|
||||||
"clsx": "^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@milkdown/transformer": {
|
"node_modules/@milkdown/transformer": {
|
||||||
"version": "7.18.0",
|
"version": "7.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/@milkdown/transformer/-/transformer-7.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/@milkdown/transformer/-/transformer-7.18.0.tgz",
|
||||||
@@ -14777,6 +14751,17 @@
|
|||||||
"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",
|
||||||
@@ -16761,6 +16746,12 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -47,9 +47,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/geist": "^5.2.8",
|
"@fontsource/geist": "^5.2.8",
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
|
"@milkdown/crepe": "^7.18.0",
|
||||||
"@milkdown/kit": "^7.18.0",
|
"@milkdown/kit": "^7.18.0",
|
||||||
"@milkdown/react": "^7.18.0",
|
|
||||||
"@milkdown/theme-nord": "^7.18.0",
|
|
||||||
"@tailwindcss/vite": "^4.2.0",
|
"@tailwindcss/vite": "^4.2.0",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tanstack/react-router": "^1.161.1",
|
"@tanstack/react-router": "^1.161.1",
|
||||||
@@ -64,6 +63,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
4
prd.json
4
prd.json
@@ -333,8 +333,8 @@
|
|||||||
"Typecheck passes"
|
"Typecheck passes"
|
||||||
],
|
],
|
||||||
"priority": 18,
|
"priority": 18,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"notes": ""
|
"notes": "Provider-agnostic architecture: AIProvider interface + registry in src/main/ai/provider.ts allows swapping AI backends (Copilot, OpenAI, Anthropic, etc.) without changing token storage or UI. Token keyed by provider name in OS keychain via keytar."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-019",
|
"id": "US-019",
|
||||||
|
|||||||
35
progress.txt
35
progress.txt
@@ -316,3 +316,38 @@
|
|||||||
- `useRef` for curtain open state avoids stale closures in `useEffect` wheel/keyboard handlers — the boolean ref is updated synchronously alongside `useState` setter
|
- `useRef` for curtain open state avoids stale closures in `useEffect` wheel/keyboard handlers — the boolean ref is updated synchronously alongside `useState` setter
|
||||||
- `{ passive: true }` on wheel listener is correct when not calling `preventDefault()` — avoids Chrome console warnings
|
- `{ passive: true }` on wheel listener is correct when not calling `preventDefault()` — avoids Chrome console warnings
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-02-23 - US-018
|
||||||
|
- What was implemented:
|
||||||
|
- Installed `keytar` native module for OS keychain token storage; added to `vite.main.config.mts` externals
|
||||||
|
- Created provider-agnostic AI architecture under `src/main/ai/`:
|
||||||
|
- `token.ts` — keychain CRUD via keytar with safeStorage fallback (handles missing libsecret on WSL/Linux), keyed by provider name
|
||||||
|
- `provider.ts` — `AIProvider` interface with `name`, `displayName`, `initialize(token)`, `isReady()` methods; provider registry (`Map`), `initAI()` startup function, `saveTokenAndInit()`, `hasActiveToken()`
|
||||||
|
- `copilot.ts` — GitHub Copilot provider implementation (initial default); registers via `registerProvider()` on import
|
||||||
|
- Updated `src/main/store.ts` — added `aiProvider: string` and `encryptedTokens: Record<string, string>` to `AppSettings`
|
||||||
|
- Updated `src/main/index.ts` — made `app.on('ready')` async, calls `await initAI()` after `initDb()`
|
||||||
|
- Updated `src/main/router/index.ts` — `ai.setToken` mutation calls `saveTokenAndInit(input.token)`, `ai.hasToken` query calls `hasActiveToken()`
|
||||||
|
- Created `/settings` route at `src/renderer/routes/settings.tsx`:
|
||||||
|
- Full settings page with left nav sidebar (shadcn pattern) + content area
|
||||||
|
- "AI Provider" section with password Input for token, Save Button, green "Saved" feedback
|
||||||
|
- Shows "A token is currently stored" indicator when `ai.hasToken` returns true
|
||||||
|
- Invalidates `ai.hasToken` query cache on successful save
|
||||||
|
- Updated `src/renderer/components/layout/AppShell.tsx`:
|
||||||
|
- Settings `SidebarMenuButton` with gear icon in sidebar footer links to `/settings` route
|
||||||
|
- Removed Dialog-based settings in favor of full route page
|
||||||
|
- Clean separation: no settings state lifted to AppShell
|
||||||
|
- Updated `src/renderer/components/ai/AIChatPanel.tsx`:
|
||||||
|
- Calls `trpc.ai.hasToken.useQuery()`; if `false`, renders `Card` with `KeyRound` icon + "AI provider not configured" + Link to `/settings`
|
||||||
|
- If `true`, shows existing "AI Chat — coming soon" placeholder
|
||||||
|
- Typecheck passes (zero errors)
|
||||||
|
- Files changed: `package.json`, `package-lock.json`, `vite.main.config.mts`, `src/main/ai/token.ts` (new), `src/main/ai/provider.ts` (new), `src/main/ai/copilot.ts` (new), `src/main/store.ts`, `src/main/index.ts`, `src/main/router/index.ts`, `src/renderer/routes/settings.tsx` (new), `src/renderer/components/layout/AppShell.tsx`, `src/renderer/components/ai/AIChatPanel.tsx`, `src/renderer/routeTree.gen.ts` (auto-generated), `prd.json`, `progress.txt`
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- `keytar` requires `libsecret` on Linux (system dependency) — on WSL it's often missing. Use try/catch lazy require + Electron `safeStorage` as fallback to keep the app functional everywhere
|
||||||
|
- `safeStorage.encryptString()` returns a Buffer; store as base64 in electron-store. `safeStorage.decryptString()` takes a Buffer back.
|
||||||
|
- Provider-agnostic pattern: `AIProvider` interface + `Map<string, AIProvider>` registry + `electron-store` for active provider name = swap providers by adding a new implementation + `registerProvider()` call
|
||||||
|
- `initAI()` uses dynamic `await import('./copilot')` to trigger side-effect registration before reading the provider from the registry
|
||||||
|
- Settings as a full route (not a Dialog) is better for extensibility — left nav allows adding sections (Appearance, Notifications, etc.) without cluttering the sidebar
|
||||||
|
- TanStack Router route tree must be regenerated after adding a new route file: `npx @tanstack/router-cli generate` or just `npm start` (Vite plugin does it)
|
||||||
|
- Token is stored per-provider so multiple providers can have tokens stored simultaneously; switching providers just changes which key is read
|
||||||
|
- `electron-store` dot-notation access (`store.get('a.b')`) works but loses type safety; prefer `store.get('encryptedTokens')` then access the nested key on the result object
|
||||||
|
---
|
||||||
|
|||||||
26
src/main/ai/copilot.ts
Normal file
26
src/main/ai/copilot.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { registerProvider, type AIProvider } from './provider';
|
||||||
|
|
||||||
|
let token: string | null = null;
|
||||||
|
|
||||||
|
const copilotProvider: AIProvider = {
|
||||||
|
name: 'copilot',
|
||||||
|
displayName: 'GitHub Copilot',
|
||||||
|
|
||||||
|
async initialize(t: string): Promise<boolean> {
|
||||||
|
token = t;
|
||||||
|
// Actual GitHub Copilot SDK client creation will be added in US-019.
|
||||||
|
// For now, having a token means the provider is ready.
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
isReady(): boolean {
|
||||||
|
return token !== null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Get the raw Copilot token (used by future chat/completion calls). */
|
||||||
|
export function getCopilotToken(): string | null {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProvider(copilotProvider);
|
||||||
80
src/main/ai/provider.ts
Normal file
80
src/main/ai/provider.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { getStore } from '../store';
|
||||||
|
import { getToken, setToken as storeToken } from './token';
|
||||||
|
|
||||||
|
export interface AIProvider {
|
||||||
|
/** Internal key, e.g. 'copilot', 'openai', 'anthropic' */
|
||||||
|
name: string;
|
||||||
|
/** Human-readable label shown in Settings UI */
|
||||||
|
displayName: string;
|
||||||
|
/** Initialize with a token. Returns true if the provider is ready. */
|
||||||
|
initialize(token: string): Promise<boolean>;
|
||||||
|
/** Whether the provider is initialized and ready to handle requests. */
|
||||||
|
isReady(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = new Map<string, AIProvider>();
|
||||||
|
let activeProvider: AIProvider | null = null;
|
||||||
|
|
||||||
|
/** Register a provider implementation. Call at import time. */
|
||||||
|
export function registerProvider(provider: AIProvider): void {
|
||||||
|
providers.set(provider.name, provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the currently active provider (may be null if none configured). */
|
||||||
|
export function getActiveProvider(): AIProvider | null {
|
||||||
|
return activeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the active provider's name from electron-store. */
|
||||||
|
export function getActiveProviderName(): string {
|
||||||
|
return getStore().get('aiProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switch to a different registered provider. */
|
||||||
|
export function setActiveProviderName(name: string): void {
|
||||||
|
const provider = providers.get(name);
|
||||||
|
if (!provider) throw new Error(`Unknown AI provider: ${name}`);
|
||||||
|
activeProvider = provider;
|
||||||
|
getStore().set('aiProvider', name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store token for the active provider and re-initialize it. */
|
||||||
|
export async function saveTokenAndInit(token: string): Promise<void> {
|
||||||
|
const name = getActiveProviderName();
|
||||||
|
await storeToken(name, token);
|
||||||
|
const provider = providers.get(name);
|
||||||
|
if (provider) {
|
||||||
|
await provider.initialize(token);
|
||||||
|
activeProvider = provider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whether the active provider has a stored token. */
|
||||||
|
export async function hasActiveToken(): Promise<boolean> {
|
||||||
|
const name = getActiveProviderName();
|
||||||
|
const token = await getToken(name);
|
||||||
|
return token !== null && token.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the AI subsystem on app startup.
|
||||||
|
* Reads the active provider from settings, loads its token from keychain,
|
||||||
|
* and calls provider.initialize() if a token exists.
|
||||||
|
*/
|
||||||
|
export async function initAI(): Promise<void> {
|
||||||
|
const name = getActiveProviderName();
|
||||||
|
const provider = providers.get(name);
|
||||||
|
if (!provider) {
|
||||||
|
console.log(`[AI] No provider registered for "${name}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getToken(name);
|
||||||
|
if (token) {
|
||||||
|
const ready = await provider.initialize(token);
|
||||||
|
activeProvider = provider;
|
||||||
|
console.log(`[AI] Provider "${provider.displayName}" initialized: ready=${ready}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[AI] No token stored for provider "${provider.displayName}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/main/ai/token.ts
Normal file
114
src/main/ai/token.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
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)
|
||||||
|
*/
|
||||||
|
|
||||||
|
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();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVICE_NAME = 'adiuva';
|
||||||
|
|
||||||
|
// --- electron-store helpers (with optional safeStorage encryption) ---
|
||||||
|
|
||||||
|
function readFromStore(providerName: string): string | null {
|
||||||
|
const tokens = getStore().get('encryptedTokens');
|
||||||
|
const stored = tokens[providerName];
|
||||||
|
if (!stored) return null;
|
||||||
|
|
||||||
|
if (canUseSafeStorage()) {
|
||||||
|
try {
|
||||||
|
return safeStorage.decryptString(Buffer.from(stored, 'base64'));
|
||||||
|
} catch {
|
||||||
|
// Stored value might be plaintext from a previous fallback
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No encryption available — value is stored as plaintext
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeToStore(providerName: string, token: string): void {
|
||||||
|
let value: string;
|
||||||
|
if (canUseSafeStorage()) {
|
||||||
|
value = safeStorage.encryptString(token).toString('base64');
|
||||||
|
} else {
|
||||||
|
// Last resort: store plaintext (WSL with no keyring)
|
||||||
|
value = token;
|
||||||
|
}
|
||||||
|
const tokens = getStore().get('encryptedTokens');
|
||||||
|
getStore().set('encryptedTokens', { ...tokens, [providerName]: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFromStore(providerName: string): void {
|
||||||
|
const tokens = getStore().get('encryptedTokens');
|
||||||
|
const { [providerName]: _, ...rest } = tokens;
|
||||||
|
getStore().set('encryptedTokens', rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- public API ---
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
removeFromStore(providerName);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -53,6 +53,14 @@ const MIGRATION_SQL = `
|
|||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS task_comments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
author TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
type DbInstance = ReturnType<typeof drizzle<typeof schema>>;
|
type DbInstance = ReturnType<typeof drizzle<typeof schema>>;
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ export const notes = sqliteTable('notes', {
|
|||||||
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
|
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const taskComments = sqliteTable('task_comments', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
taskId: text('task_id').notNull(),
|
||||||
|
author: text('author').notNull(),
|
||||||
|
content: text('content').notNull(),
|
||||||
|
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
// Inferred TypeScript types — no manual duplication
|
// Inferred TypeScript types — no manual duplication
|
||||||
export type Client = InferSelectModel<typeof clients>;
|
export type Client = InferSelectModel<typeof clients>;
|
||||||
export type NewClient = InferInsertModel<typeof clients>;
|
export type NewClient = InferInsertModel<typeof clients>;
|
||||||
@@ -64,3 +72,6 @@ export type NewCheckpoint = InferInsertModel<typeof checkpoints>;
|
|||||||
|
|
||||||
export type Note = InferSelectModel<typeof notes>;
|
export type Note = InferSelectModel<typeof notes>;
|
||||||
export type NewNote = InferInsertModel<typeof notes>;
|
export type NewNote = InferInsertModel<typeof notes>;
|
||||||
|
|
||||||
|
export type TaskComment = InferSelectModel<typeof taskComments>;
|
||||||
|
export type NewTaskComment = InferInsertModel<typeof taskComments>;
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import started from 'electron-squirrel-startup';
|
|||||||
import { initDb } from './db';
|
import { initDb } from './db';
|
||||||
import { appRouter } from './router';
|
import { appRouter } from './router';
|
||||||
import { createIPCHandler } from './ipc';
|
import { createIPCHandler } from './ipc';
|
||||||
|
import { initAI } from './ai/provider';
|
||||||
|
// Import to trigger provider registration before initAI() runs
|
||||||
|
import './ai/copilot';
|
||||||
|
|
||||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||||
if (started) {
|
if (started) {
|
||||||
@@ -49,6 +52,8 @@ app.on('ready', () => {
|
|||||||
initDb();
|
initDb();
|
||||||
const win = createWindow();
|
const win = createWindow();
|
||||||
createIPCHandler({ router: appRouter, windows: [win] });
|
createIPCHandler({ router: appRouter, windows: [win] });
|
||||||
|
// AI init is best-effort — never block window creation
|
||||||
|
initAI().catch((err) => console.error('[AI] Init failed:', err));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Quit when all windows are closed, except on macOS. There, it's common
|
// Quit when all windows are closed, except on macOS. There, it's common
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { z } from 'zod';
|
|||||||
import { eq, asc, inArray, and, or, like, sql } from 'drizzle-orm';
|
import { eq, asc, inArray, and, or, like, sql } from 'drizzle-orm';
|
||||||
import { alias } from 'drizzle-orm/sqlite-core';
|
import { alias } from 'drizzle-orm/sqlite-core';
|
||||||
import { getDb } from '../db';
|
import { getDb } from '../db';
|
||||||
import { clients, projects, tasks, checkpoints, notes } from '../db/schema';
|
import { clients, projects, tasks, checkpoints, notes, taskComments } from '../db/schema';
|
||||||
import { getStore } from '../store';
|
import { getStore } from '../store';
|
||||||
|
import { saveTokenAndInit, hasActiveToken } from '../ai/provider';
|
||||||
|
|
||||||
const t = initTRPC.create();
|
const t = initTRPC.create();
|
||||||
|
|
||||||
@@ -199,12 +200,13 @@ const tasksRouter = router({
|
|||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const orderByClause =
|
const priorityExpr = sql`CASE ${tasks.priority} WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`;
|
||||||
|
const orderByClauses =
|
||||||
input?.orderBy === 'dueDate'
|
input?.orderBy === 'dueDate'
|
||||||
? asc(tasks.dueDate)
|
? [asc(tasks.dueDate), asc(priorityExpr)]
|
||||||
: input?.orderBy === 'priority'
|
: input?.orderBy === 'priority'
|
||||||
? asc(sql`CASE ${tasks.priority} WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`)
|
? [asc(priorityExpr), asc(tasks.dueDate)]
|
||||||
: asc(tasks.createdAt);
|
: [asc(tasks.dueDate), asc(priorityExpr)];
|
||||||
|
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
@@ -226,7 +228,7 @@ const tasksRouter = router({
|
|||||||
.leftJoin(clients, eq(projects.clientId, clients.id))
|
.leftJoin(clients, eq(projects.clientId, clients.id))
|
||||||
.leftJoin(parentClients, eq(clients.parentId, parentClients.id))
|
.leftJoin(parentClients, eq(clients.parentId, parentClients.id))
|
||||||
.where(conditions)
|
.where(conditions)
|
||||||
.orderBy(orderByClause)
|
.orderBy(...orderByClauses)
|
||||||
.all();
|
.all();
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -436,6 +438,41 @@ const notesRouter = router({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const taskCommentsRouter = router({
|
||||||
|
list: publicProcedure
|
||||||
|
.input(z.object({ taskId: z.string() }))
|
||||||
|
.query(({ input }) => {
|
||||||
|
return getDb()
|
||||||
|
.select()
|
||||||
|
.from(taskComments)
|
||||||
|
.where(eq(taskComments.taskId, input.taskId))
|
||||||
|
.orderBy(asc(taskComments.createdAt))
|
||||||
|
.all();
|
||||||
|
}),
|
||||||
|
|
||||||
|
create: publicProcedure
|
||||||
|
.input(z.object({ taskId: z.string(), author: z.string(), content: z.string() }))
|
||||||
|
.mutation(({ input }) => {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = Date.now();
|
||||||
|
getDb().insert(taskComments).values({
|
||||||
|
id,
|
||||||
|
taskId: input.taskId,
|
||||||
|
author: input.author,
|
||||||
|
content: input.content,
|
||||||
|
createdAt: now,
|
||||||
|
}).run();
|
||||||
|
return { id, taskId: input.taskId, author: input.author, content: input.content, createdAt: now };
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: publicProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(({ input }) => {
|
||||||
|
getDb().delete(taskComments).where(eq(taskComments.id, input.id)).run();
|
||||||
|
return { success: true as const };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const settingsRouter = router({
|
const settingsRouter = router({
|
||||||
getSidebarCollapsed: publicProcedure.query(() => getStore().get('sidebarCollapsed')),
|
getSidebarCollapsed: publicProcedure.query(() => getStore().get('sidebarCollapsed')),
|
||||||
setSidebarCollapsed: publicProcedure
|
setSidebarCollapsed: publicProcedure
|
||||||
@@ -458,8 +495,13 @@ const aiRouter = router({
|
|||||||
.mutation(() => ({ response: '' })),
|
.mutation(() => ({ response: '' })),
|
||||||
setToken: publicProcedure
|
setToken: publicProcedure
|
||||||
.input(z.object({ token: z.string() }))
|
.input(z.object({ token: z.string() }))
|
||||||
.mutation(() => null),
|
.mutation(async ({ input }) => {
|
||||||
hasToken: publicProcedure.query(() => false),
|
await saveTokenAndInit(input.token);
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
hasToken: publicProcedure.query(async () => {
|
||||||
|
return hasActiveToken();
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
@@ -470,6 +512,7 @@ export const appRouter = router({
|
|||||||
tasks: tasksRouter,
|
tasks: tasksRouter,
|
||||||
checkpoints: checkpointsRouter,
|
checkpoints: checkpointsRouter,
|
||||||
notes: notesRouter,
|
notes: notesRouter,
|
||||||
|
taskComments: taskCommentsRouter,
|
||||||
ai: aiRouter,
|
ai: aiRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import Store from 'electron-store';
|
|||||||
|
|
||||||
interface AppSettings {
|
interface AppSettings {
|
||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
|
aiProvider: string;
|
||||||
|
encryptedTokens: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let _store: Store<AppSettings> | null = null;
|
let _store: Store<AppSettings> | null = null;
|
||||||
@@ -11,6 +13,8 @@ export function getStore(): Store<AppSettings> {
|
|||||||
_store = new Store<AppSettings>({
|
_store = new Store<AppSettings>({
|
||||||
defaults: {
|
defaults: {
|
||||||
sidebarCollapsed: false,
|
sidebarCollapsed: false,
|
||||||
|
aiProvider: 'copilot',
|
||||||
|
encryptedTokens: {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,36 @@
|
|||||||
import { Sparkles } from 'lucide-react';
|
import { Sparkles, KeyRound } from 'lucide-react';
|
||||||
|
import { trpc } from '@/lib/trpc';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface AIChatPanelProps {
|
||||||
|
onOpenSettings?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AIChatPanel({ onOpenSettings }: AIChatPanelProps) {
|
||||||
|
const hasTokenQuery = trpc.ai.hasToken.useQuery();
|
||||||
|
|
||||||
|
if (hasTokenQuery.data === false) {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 z-0 flex flex-col items-center justify-center bg-background">
|
||||||
|
<Card className="max-w-sm">
|
||||||
|
<CardContent className="flex flex-col items-center gap-4 pt-6">
|
||||||
|
<KeyRound size={32} className="text-muted-foreground" />
|
||||||
|
<div className="text-center space-y-1">
|
||||||
|
<p className="text-sm font-medium">AI provider not configured</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Connect your GitHub Copilot token to enable AI-powered features like chat, summaries, and suggestions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={onOpenSettings}>
|
||||||
|
Open Settings
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AIChatPanel() {
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-0 flex flex-col items-center justify-center bg-background">
|
<div className="absolute inset-0 z-0 flex flex-col items-center justify-center bg-background">
|
||||||
<Sparkles size={32} className="text-muted-foreground/40 mb-3" />
|
<Sparkles size={32} className="text-muted-foreground/40 mb-3" />
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ import {
|
|||||||
PanelLeft,
|
PanelLeft,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Settings,
|
||||||
|
Sparkles,
|
||||||
|
Check,
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
|
Monitor,
|
||||||
|
Palette
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
import {
|
import {
|
||||||
@@ -25,7 +32,30 @@ import {
|
|||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { AIChatPanel } from '@/components/ai/AIChatPanel';
|
import { AIChatPanel } from '@/components/ai/AIChatPanel';
|
||||||
|
import { useTheme } from '@/components/theme-provider';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ to: '/', icon: House, label: 'Home' },
|
{ to: '/', icon: House, label: 'Home' },
|
||||||
@@ -72,6 +102,21 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
setSidebarCollapsedMutation.mutate({ collapsed: !value });
|
setSidebarCollapsedMutation.mutate({ collapsed: !value });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// AI token dialog state (shared between sidebar gear menu and AIChatPanel prompt)
|
||||||
|
const [tokenDialogOpen, setTokenDialogOpen] = useState(false);
|
||||||
|
const [tokenInput, setTokenInput] = useState('');
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const hasTokenQuery = trpc.ai.hasToken.useQuery();
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
const setTokenMutation = trpc.ai.setToken.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setSaved(true);
|
||||||
|
setTokenInput('');
|
||||||
|
void utils.ai.hasToken.invalidate();
|
||||||
|
setTimeout(() => setSaved(false), 2000);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Curtain is disabled on home page and on /projects without a selected project
|
// Curtain is disabled on home page and on /projects without a selected project
|
||||||
const searchObj = routerState.location.search as Record<string, unknown>;
|
const searchObj = routerState.location.search as Record<string, unknown>;
|
||||||
const curtainEnabled =
|
const curtainEnabled =
|
||||||
@@ -141,11 +186,15 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
}, [openCurtain, closeCurtain]);
|
}, [openCurtain, closeCurtain]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<SidebarProvider open={open} onOpenChange={handleOpenChange}>
|
<SidebarProvider open={open} onOpenChange={handleOpenChange}>
|
||||||
<AppSidebar currentPath={currentPath} />
|
<AppSidebar
|
||||||
|
currentPath={currentPath}
|
||||||
|
setTokenDialogOpen={setTokenDialogOpen}
|
||||||
|
/>
|
||||||
<SidebarInset className="overflow-hidden">
|
<SidebarInset className="overflow-hidden">
|
||||||
{/* AI Chat layer: always mounted behind the content panel */}
|
{/* AI Chat layer: always mounted behind the content panel */}
|
||||||
<AIChatPanel />
|
<AIChatPanel onOpenSettings={() => setTokenDialogOpen(true)} />
|
||||||
|
|
||||||
{/* Content panel: slides down to reveal chat */}
|
{/* Content panel: slides down to reveal chat */}
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -158,12 +207,12 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
<div className={`absolute right-0 top-0 flex items-end justify-center pt-8 pointer-events-none select-none${!curtainEnabled ? ' hidden' : ''}`}>
|
<div className={`absolute right-0 top-0 flex items-end justify-center pt-8 pointer-events-none select-none${!curtainEnabled ? ' hidden' : ''}`}>
|
||||||
<div className="flex flex-col items-center gap-1.5 pr-2">
|
<div className="flex flex-col items-center gap-1.5 pr-2">
|
||||||
{curtainOpen ? (
|
{curtainOpen ? (
|
||||||
<ChevronDown size={10} className="text-muted-foreground/30" />
|
<ChevronDown size={10} />
|
||||||
) : (
|
) : (
|
||||||
<ChevronUp size={10} className="text-muted-foreground/30" />
|
<ChevronUp size={10} />
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className="text-[9px] text-muted-foreground/30 tracking-widest uppercase font-medium"
|
className="text-[9px] tracking-widest uppercase font-medium"
|
||||||
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
|
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
|
||||||
>
|
>
|
||||||
{curtainOpen ? 'back to app' : 'scrolling up for Adiuva'}
|
{curtainOpen ? 'back to app' : 'scrolling up for Adiuva'}
|
||||||
@@ -173,11 +222,62 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|
||||||
|
{/* AI Token Dialog — rendered outside Sidebar to avoid layout conflicts */}
|
||||||
|
<Dialog open={tokenDialogOpen} onOpenChange={(open) => {
|
||||||
|
setTokenDialogOpen(open);
|
||||||
|
if (!open) { setTokenInput(''); setSaved(false); }
|
||||||
|
}}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>AI Provider</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure your AI provider credentials for chat, summaries, and suggestions.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">GitHub Copilot Token</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Paste your token here"
|
||||||
|
value={tokenInput}
|
||||||
|
onChange={(e) => setTokenInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
{saved && (
|
||||||
|
<span className="flex items-center gap-1 text-sm text-green-600 mr-auto">
|
||||||
|
<Check size={14} />
|
||||||
|
Saved
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
disabled={!tokenInput.trim() || setTokenMutation.isPending}
|
||||||
|
onClick={() => setTokenMutation.mutate({ token: tokenInput.trim() })}
|
||||||
|
>
|
||||||
|
{setTokenMutation.isPending ? 'Saving...' : 'Save Token'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppSidebar({ currentPath }: { currentPath: string }) {
|
interface AppSidebarProps {
|
||||||
|
currentPath: string;
|
||||||
|
setTokenDialogOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
|
||||||
const { toggleSidebar } = useSidebar();
|
const { toggleSidebar } = useSidebar();
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon">
|
<Sidebar collapsible="icon">
|
||||||
@@ -241,9 +341,50 @@ function AppSidebar({ currentPath }: { currentPath: string }) {
|
|||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
{/* Collapse toggle — spec: useSidebar() + custom trigger */}
|
{/* Settings gear + Collapse toggle */}
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<SidebarMenuButton tooltip="Settings">
|
||||||
|
<Settings />
|
||||||
|
<span>Settings</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent side="right" align="end" className="w-56">
|
||||||
|
<DropdownMenuItem onSelect={() => setTokenDialogOpen(true)}>
|
||||||
|
<Sparkles className="mr-2 size-4" />
|
||||||
|
AI Provider
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>
|
||||||
|
<Palette className="mr-2 size-4" />
|
||||||
|
<span>Theme</span>
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
<DropdownMenuItem onSelect={() => setTheme('light')}>
|
||||||
|
<Sun className="mr-2 size-4" />
|
||||||
|
Light
|
||||||
|
{theme === 'light' && <Check className="ml-auto size-4" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => setTheme('dark')}>
|
||||||
|
<Moon className="mr-2 size-4" />
|
||||||
|
Dark
|
||||||
|
{theme === 'dark' && <Check className="ml-auto size-4" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => setTheme('system')}>
|
||||||
|
<Monitor className="mr-2 size-4" />
|
||||||
|
System
|
||||||
|
{theme === 'system' && <Check className="ml-auto size-4" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton onClick={toggleSidebar} tooltip="Toggle Sidebar">
|
<SidebarMenuButton onClick={toggleSidebar} tooltip="Toggle Sidebar">
|
||||||
<PanelLeft />
|
<PanelLeft />
|
||||||
@@ -252,6 +393,7 @@ function AppSidebar({ currentPath }: { currentPath: string }) {
|
|||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
|
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,49 @@
|
|||||||
import { useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Editor, rootCtx, defaultValueCtx } from '@milkdown/kit/core';
|
import { Crepe, CrepeFeature } from '@milkdown/crepe';
|
||||||
import { commonmark } from '@milkdown/kit/preset/commonmark';
|
|
||||||
import { history } from '@milkdown/kit/plugin/history';
|
|
||||||
import { listener, listenerCtx } from '@milkdown/kit/plugin/listener';
|
|
||||||
import { Milkdown, MilkdownProvider, useEditor } from '@milkdown/react';
|
|
||||||
|
|
||||||
import '@milkdown/kit/prose/view/style/prosemirror.css';
|
import '@milkdown/crepe/theme/common/style.css';
|
||||||
|
import '@milkdown/crepe/theme/nord.css';
|
||||||
|
|
||||||
interface MilkdownEditorProps {
|
interface MilkdownEditorProps {
|
||||||
initialContent: string;
|
initialContent: string;
|
||||||
onChange: (markdown: string) => void;
|
onChange: (markdown: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MilkdownInner({ initialContent, onChange }: MilkdownEditorProps) {
|
export function MilkdownEditor({ initialContent, onChange }: MilkdownEditorProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const crepeRef = useRef<Crepe | null>(null);
|
||||||
const onChangeRef = useRef(onChange);
|
const onChangeRef = useRef(onChange);
|
||||||
onChangeRef.current = onChange;
|
onChangeRef.current = onChange;
|
||||||
|
|
||||||
useEditor((root) =>
|
useEffect(() => {
|
||||||
Editor.make()
|
if (!containerRef.current) return;
|
||||||
.config((ctx) => {
|
|
||||||
ctx.set(rootCtx, root);
|
const crepe = new Crepe({
|
||||||
ctx.set(defaultValueCtx, initialContent);
|
root: containerRef.current,
|
||||||
})
|
defaultValue: initialContent,
|
||||||
.use(commonmark)
|
featureConfigs: {
|
||||||
.use(history)
|
[CrepeFeature.Placeholder]: {
|
||||||
.use(listener)
|
text: 'Start writing...',
|
||||||
.config((ctx) => {
|
},
|
||||||
ctx.get(listenerCtx).markdownUpdated((_ctx, markdown, prevMarkdown) => {
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
crepe.on((listener) => {
|
||||||
|
listener.markdownUpdated((_ctx, markdown, prevMarkdown) => {
|
||||||
if (markdown !== prevMarkdown) {
|
if (markdown !== prevMarkdown) {
|
||||||
onChangeRef.current(markdown);
|
onChangeRef.current(markdown);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}),
|
});
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return <Milkdown />;
|
crepe.create();
|
||||||
}
|
crepeRef.current = crepe;
|
||||||
|
|
||||||
export function MilkdownEditor(props: MilkdownEditorProps) {
|
return () => {
|
||||||
return (
|
crepe.destroy();
|
||||||
<MilkdownProvider>
|
crepeRef.current = null;
|
||||||
<MilkdownInner {...props} />
|
};
|
||||||
</MilkdownProvider>
|
}, []);
|
||||||
);
|
|
||||||
|
return <div ref={containerRef} className="milkdown-container" />;
|
||||||
}
|
}
|
||||||
|
|||||||
274
src/renderer/components/tasks/TaskDetailDialog.tsx
Normal file
274
src/renderer/components/tasks/TaskDetailDialog.tsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
User,
|
||||||
|
CircleDot,
|
||||||
|
FolderOpen,
|
||||||
|
Zap,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Send,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { trpc } from '@/lib/trpc';
|
||||||
|
import { PriorityBadge } from './PriorityBadge';
|
||||||
|
import { parseAssignees, type TaskItem } from './TaskRow';
|
||||||
|
|
||||||
|
function formatDate(timestamp: number): string {
|
||||||
|
const d = new Date(timestamp);
|
||||||
|
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
return `${months[d.getMonth()]} ${String(d.getDate()).padStart(2, '0')}, ${d.getFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeTime(timestamp: number): string {
|
||||||
|
const diff = Date.now() - timestamp;
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
if (minutes < 1) return 'just now';
|
||||||
|
if (minutes < 60) return `${minutes} min ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours} hr ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<string, { label: string; className: string }> = {
|
||||||
|
todo: { label: 'To Do', className: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' },
|
||||||
|
in_progress: { label: 'In Progress', className: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' },
|
||||||
|
done: { label: 'Done', className: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function AuthorAvatar({ name }: { name: string }) {
|
||||||
|
const initials = name
|
||||||
|
.split(/\s+/)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((w) => w[0]?.toUpperCase() ?? '')
|
||||||
|
.join('');
|
||||||
|
return (
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskDetailDialogProps {
|
||||||
|
task: TaskItem | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onEdit: (task: TaskItem) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskDetailDialog({ task, open, onOpenChange, onEdit, onDelete }: TaskDetailDialogProps) {
|
||||||
|
const [commentText, setCommentText] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState('description');
|
||||||
|
|
||||||
|
const { data: comments } = trpc.taskComments.list.useQuery(
|
||||||
|
{ taskId: task?.id ?? '' },
|
||||||
|
{ enabled: !!task },
|
||||||
|
);
|
||||||
|
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
|
const addComment = trpc.taskComments.create.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.taskComments.list.invalidate({ taskId: task?.id ?? '' });
|
||||||
|
setCommentText('');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteComment = trpc.taskComments.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.taskComments.list.invalidate({ taskId: task?.id ?? '' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!task) return null;
|
||||||
|
|
||||||
|
const assignees = parseAssignees(task.assignee);
|
||||||
|
const statusConf = STATUS_CONFIG[task.status ?? 'todo'] ?? { label: 'To Do', className: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' };
|
||||||
|
const breadcrumb = [task.clientName, task.subClientName, task.projectName].filter(Boolean);
|
||||||
|
|
||||||
|
const handleAddComment = () => {
|
||||||
|
const text = commentText.trim();
|
||||||
|
if (!text) return;
|
||||||
|
addComment.mutate({ taskId: task.id, author: 'Me', content: text });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[620px] gap-0 p-0" aria-describedby={undefined}>
|
||||||
|
{/* Header */}
|
||||||
|
<DialogHeader className="px-6 pt-6 pb-4">
|
||||||
|
<DialogTitle className="text-lg font-semibold leading-tight">{task.title}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Field rows */}
|
||||||
|
<div className="grid grid-cols-[120px_1fr] gap-y-3 px-6 py-4 text-sm">
|
||||||
|
{/* Assignee */}
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
Assignee
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{assignees.length > 0 ? (
|
||||||
|
assignees.map((name) => (
|
||||||
|
<Badge key={name} variant="secondary" className="text-xs">
|
||||||
|
{name}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">Unassigned</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<CircleDot className="h-4 w-4" />
|
||||||
|
Status
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${statusConf.className}`}>
|
||||||
|
{statusConf.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Due date */}
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
Due date
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{task.dueDate ? formatDate(task.dueDate) : <span className="text-muted-foreground">No due date</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority */}
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Zap className="h-4 w-4" />
|
||||||
|
Priority
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<PriorityBadge priority={task.priority} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project */}
|
||||||
|
{breadcrumb.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
Project
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">{breadcrumb.join(' > ')}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Tabs: Description / Comment */}
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col">
|
||||||
|
<TabsList className="mx-6 mt-3 w-fit">
|
||||||
|
<TabsTrigger value="description">Description</TabsTrigger>
|
||||||
|
<TabsTrigger value="comment">Comment</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="description" className="px-6 py-4 min-h-[120px]">
|
||||||
|
{task.description ? (
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{task.description}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No description provided.</p>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="comment" className="px-6 py-4 min-h-[120px] flex flex-col gap-4">
|
||||||
|
{/* Comment list */}
|
||||||
|
<div className="flex flex-col gap-4 max-h-[260px] overflow-y-auto">
|
||||||
|
{(!comments || comments.length === 0) ? (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No comments yet.</p>
|
||||||
|
) : (
|
||||||
|
comments.map((c) => (
|
||||||
|
<div key={c.id} className="flex gap-3">
|
||||||
|
<AuthorAvatar name={c.author} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-sm font-medium">{c.author}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{relativeTime(c.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted px-3 py-2 text-sm">
|
||||||
|
{c.content}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => deleteComment.mutate({ id: c.id })}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add comment input */}
|
||||||
|
<form
|
||||||
|
className="flex items-center gap-2 mt-auto"
|
||||||
|
onSubmit={(e) => { e.preventDefault(); handleAddComment(); }}
|
||||||
|
>
|
||||||
|
<AuthorAvatar name="Me" />
|
||||||
|
<Input
|
||||||
|
placeholder="Add a comment..."
|
||||||
|
value={commentText}
|
||||||
|
onChange={(e) => setCommentText(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!commentText.trim() || addComment.isPending}
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<DialogFooter className="px-6 py-4">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { onDelete(task.id); onOpenChange(false); }}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { onEdit(task); onOpenChange(false); }}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4 mr-1" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,7 +41,11 @@ export function parseAssignees(raw: string | null): string[] {
|
|||||||
function formatDueDate(timestamp: number): string {
|
function formatDueDate(timestamp: number): string {
|
||||||
const d = new Date(timestamp);
|
const d = new Date(timestamp);
|
||||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
return `Due ${months[d.getMonth()]} ${d.getDate()}`;
|
const date = `Due ${months[d.getMonth()]} ${d.getDate()}`;
|
||||||
|
if (d.getHours() === 0 && d.getMinutes() === 0) return date;
|
||||||
|
const h = String(d.getHours()).padStart(2, '0');
|
||||||
|
const m = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
return `${date}, ${h}:${m}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TaskRow({
|
export function TaskRow({
|
||||||
@@ -49,12 +53,14 @@ export function TaskRow({
|
|||||||
onToggle,
|
onToggle,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onClick,
|
||||||
hideBreadcrumb,
|
hideBreadcrumb,
|
||||||
}: {
|
}: {
|
||||||
task: TaskItem;
|
task: TaskItem;
|
||||||
onToggle: (id: string, status: string | null) => void;
|
onToggle: (id: string, status: string | null) => void;
|
||||||
onEdit?: (task: TaskItem) => void;
|
onEdit?: (task: TaskItem) => void;
|
||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
|
onClick?: (task: TaskItem) => void;
|
||||||
hideBreadcrumb?: boolean;
|
hideBreadcrumb?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const isDone = task.status === 'done';
|
const isDone = task.status === 'done';
|
||||||
@@ -80,15 +86,17 @@ export function TaskRow({
|
|||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col gap-1.5 px-4 py-3 rounded-md border cursor-default select-none ${
|
className={`flex flex-col gap-1.5 px-4 py-3 rounded-md border select-none transition-colors ${
|
||||||
isDone ? 'bg-green-50 border-green-200' : 'bg-white border-border'
|
isDone ? 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900' : 'bg-card border-border'
|
||||||
}`}
|
} ${onClick ? 'cursor-pointer hover:bg-accent/50' : 'cursor-default'}`}
|
||||||
|
onClick={() => onClick?.(task)}
|
||||||
>
|
>
|
||||||
{/* Row 1: checkbox + title + description */}
|
{/* Row 1: checkbox + title + description */}
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={checkboxState}
|
checked={checkboxState}
|
||||||
onCheckedChange={() => onToggle(task.id, task.status)}
|
onCheckedChange={() => onToggle(task.id, task.status)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
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">
|
||||||
|
|||||||
72
src/renderer/components/theme-provider.tsx
Normal file
72
src/renderer/components/theme-provider.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState } from "react"
|
||||||
|
|
||||||
|
type Theme = "dark" | "light" | "system"
|
||||||
|
|
||||||
|
type ThemeProviderProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
defaultTheme?: Theme
|
||||||
|
storageKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThemeProviderState = {
|
||||||
|
theme: Theme
|
||||||
|
setTheme: (theme: Theme) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ThemeProviderState = {
|
||||||
|
theme: "system",
|
||||||
|
setTheme: () => null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||||
|
|
||||||
|
export function ThemeProvider({
|
||||||
|
children,
|
||||||
|
defaultTheme = "system",
|
||||||
|
storageKey = "adiuva-theme",
|
||||||
|
...props
|
||||||
|
}: ThemeProviderProps) {
|
||||||
|
const [theme, setTheme] = useState<Theme>(
|
||||||
|
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = window.document.documentElement
|
||||||
|
|
||||||
|
root.classList.remove("light", "dark")
|
||||||
|
|
||||||
|
if (theme === "system") {
|
||||||
|
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||||
|
.matches
|
||||||
|
? "dark"
|
||||||
|
: "light"
|
||||||
|
root.classList.add(systemTheme)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
root.classList.add(theme)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
theme,
|
||||||
|
setTheme: (theme: Theme) => {
|
||||||
|
localStorage.setItem(storageKey, theme)
|
||||||
|
setTheme(theme)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProviderContext.Provider {...props} value={value}>
|
||||||
|
{children}
|
||||||
|
</ThemeProviderContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const context = useContext(ThemeProviderContext)
|
||||||
|
|
||||||
|
if (context === undefined)
|
||||||
|
throw new Error("useTheme must be used within a ThemeProvider")
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -141,141 +141,47 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Milkdown editor overrides */
|
/* Crepe editor layout */
|
||||||
[data-milkdown-root] {
|
.milkdown-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.milkdown {
|
.milkdown-container .milkdown {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
color: var(--foreground);
|
--crepe-color-background: var(--background);
|
||||||
font-family: inherit;
|
--crepe-font-default: 'Geist', 'Inter', system-ui, sans-serif;
|
||||||
line-height: 1.75;
|
--crepe-font-title: 'Geist', 'Inter', system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.milkdown .editor {
|
/* Override Crepe's default 60px 120px padding for panel use.
|
||||||
|
Left padding >=72px to leave room for the block handle (plus + drag buttons). */
|
||||||
|
.milkdown-container .milkdown .ProseMirror {
|
||||||
|
@apply pr-6 pl-18 py-0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
outline: none;
|
|
||||||
padding: 0;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
word-break: break-word;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.milkdown .editor > * + * {
|
/* Dark theme: scope nord-dark variables under .dark class */
|
||||||
margin-top: 0.75em;
|
.dark .milkdown {
|
||||||
}
|
--crepe-color-on-background: #f8f9ff;
|
||||||
|
--crepe-color-surface: #111418;
|
||||||
.milkdown .editor h1 {
|
--crepe-color-surface-low: #191c20;
|
||||||
font-size: 1.875rem;
|
--crepe-color-on-surface: #e1e2e8;
|
||||||
font-weight: 700;
|
--crepe-color-on-surface-variant: #c3c6cf;
|
||||||
line-height: 1.2;
|
--crepe-color-outline: #8d9199;
|
||||||
color: var(--foreground);
|
--crepe-color-primary: #a1c9fd;
|
||||||
}
|
--crepe-color-secondary: #3c4858;
|
||||||
|
--crepe-color-on-secondary: #d7e3f8;
|
||||||
.milkdown .editor h2 {
|
--crepe-color-inverse: #e1e2e8;
|
||||||
font-size: 1.5rem;
|
--crepe-color-on-inverse: #2e3135;
|
||||||
font-weight: 600;
|
--crepe-color-inline-code: #ffb4ab;
|
||||||
line-height: 1.3;
|
--crepe-color-error: #ffb4ab;
|
||||||
color: var(--foreground);
|
--crepe-color-hover: #1d2024;
|
||||||
}
|
--crepe-color-selected: #32353a;
|
||||||
|
--crepe-color-inline-area: #111418;
|
||||||
.milkdown .editor h3 {
|
--crepe-shadow-1: 0px 1px 2px 0px rgba(255, 255, 255, 0.3), 0px 1px 3px 1px rgba(255, 255, 255, 0.15);
|
||||||
font-size: 1.25rem;
|
--crepe-shadow-2: 0px 1px 2px 0px rgba(255, 255, 255, 0.3), 0px 2px 6px 2px rgba(255, 255, 255, 0.15);
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.4;
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .editor h4 {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .editor h5,
|
|
||||||
.milkdown .editor h6 {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .editor p {
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .editor blockquote {
|
|
||||||
border-left: 3px solid var(--border);
|
|
||||||
padding-left: 1rem;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .editor pre {
|
|
||||||
background: var(--muted);
|
|
||||||
color: var(--foreground);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .editor code {
|
|
||||||
background: var(--muted);
|
|
||||||
color: var(--foreground);
|
|
||||||
padding: 0.125rem 0.375rem;
|
|
||||||
border-radius: calc(var(--radius) - 4px);
|
|
||||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
|
||||||
font-size: 0.875em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .editor pre code {
|
|
||||||
background: transparent;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .editor ul,
|
|
||||||
.milkdown .editor ol {
|
|
||||||
padding-left: 1.5rem;
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .editor ul {
|
|
||||||
list-style-type: disc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .editor ol {
|
|
||||||
list-style-type: decimal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .editor li + li {
|
|
||||||
margin-top: 0.25em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .editor a {
|
|
||||||
color: var(--primary);
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .editor hr {
|
|
||||||
border: none;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
margin: 1.5em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .editor strong {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown .editor em {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|||||||
import { ipcLink } from './lib/ipcLink';
|
import { ipcLink } from './lib/ipcLink';
|
||||||
import { router } from './router';
|
import { router } from './router';
|
||||||
import { trpc } from './lib/trpc';
|
import { trpc } from './lib/trpc';
|
||||||
|
import { ThemeProvider } from './components/theme-provider';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -16,11 +17,13 @@ function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ThemeProvider defaultTheme="system" storageKey="adiuva-theme">
|
||||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</trpc.Provider>
|
</trpc.Provider>
|
||||||
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
|
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
|
||||||
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
|
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
|
||||||
import { EditTaskDialog } from '@/components/tasks/EditTaskDialog';
|
import { EditTaskDialog } from '@/components/tasks/EditTaskDialog';
|
||||||
|
import { TaskDetailDialog } from '@/components/tasks/TaskDetailDialog';
|
||||||
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
|
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
|
||||||
|
|
||||||
export const Route = createFileRoute('/tasks')({
|
export const Route = createFileRoute('/tasks')({
|
||||||
@@ -42,9 +43,10 @@ function TasksPage() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||||
const [orderBy, setOrderBy] = useState<OrderBy>('createdAt');
|
const [orderBy, setOrderBy] = useState<OrderBy>('dueDate');
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editTask, setEditTask] = useState<TaskItem | null>(null);
|
const [editTask, setEditTask] = useState<TaskItem | null>(null);
|
||||||
|
const [viewTask, setViewTask] = useState<TaskItem | null>(null);
|
||||||
|
|
||||||
const debounceTimer = useMemo(() => ({ id: null as ReturnType<typeof setTimeout> | null }), []);
|
const debounceTimer = useMemo(() => ({ id: null as ReturnType<typeof setTimeout> | null }), []);
|
||||||
|
|
||||||
@@ -127,7 +129,7 @@ function TasksPage() {
|
|||||||
<ItemDescription>To Do</ItemDescription>
|
<ItemDescription>To Do</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
</Item>
|
</Item>
|
||||||
<Item variant="muted" className="bg-sky-50">
|
<Item variant="muted" className="bg-sky-50 dark:bg-sky-950/30">
|
||||||
<ItemMedia variant="icon">
|
<ItemMedia variant="icon">
|
||||||
<Loader2 />
|
<Loader2 />
|
||||||
</ItemMedia>
|
</ItemMedia>
|
||||||
@@ -136,7 +138,7 @@ function TasksPage() {
|
|||||||
<ItemDescription>In Progress</ItemDescription>
|
<ItemDescription>In Progress</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
</Item>
|
</Item>
|
||||||
<Item variant="muted" className="bg-green-50">
|
<Item variant="muted" className="bg-green-50 dark:bg-green-950/30">
|
||||||
<ItemMedia variant="icon">
|
<ItemMedia variant="icon">
|
||||||
<CheckCircle2 />
|
<CheckCircle2 />
|
||||||
</ItemMedia>
|
</ItemMedia>
|
||||||
@@ -211,6 +213,7 @@ function TasksPage() {
|
|||||||
onToggle={handleCheckboxToggle}
|
onToggle={handleCheckboxToggle}
|
||||||
onEdit={setEditTask}
|
onEdit={setEditTask}
|
||||||
onDelete={(id) => deleteTask.mutate({ id })}
|
onDelete={(id) => deleteTask.mutate({ id })}
|
||||||
|
onClick={setViewTask}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -222,6 +225,13 @@ function TasksPage() {
|
|||||||
open={!!editTask}
|
open={!!editTask}
|
||||||
onOpenChange={(open: boolean) => { if (!open) setEditTask(null); }}
|
onOpenChange={(open: boolean) => { if (!open) setEditTask(null); }}
|
||||||
/>
|
/>
|
||||||
|
<TaskDetailDialog
|
||||||
|
task={viewTask}
|
||||||
|
open={!!viewTask}
|
||||||
|
onOpenChange={(open) => { if (!open) setViewTask(null); }}
|
||||||
|
onEdit={(task) => { setViewTask(null); setEditTask(task); }}
|
||||||
|
onDelete={(id) => { deleteTask.mutate({ id }); setViewTask(null); }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ function TimelinePage() {
|
|||||||
No checkpoints yet. Click "+ Add" to create your first milestone.
|
No checkpoints yet. Click "+ Add" to create your first milestone.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="border rounded-md p-4 bg-white">
|
<div className="border rounded-md p-4 bg-card">
|
||||||
<GanttChart
|
<GanttChart
|
||||||
checkpoints={ganttCheckpoints}
|
checkpoints={ganttCheckpoints}
|
||||||
startDate={startDate}
|
startDate={startDate}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
// Externalize native Node modules — they're rebuilt by electron-forge
|
// Externalize native Node modules — they're rebuilt by electron-forge
|
||||||
external: ['better-sqlite3'],
|
external: ['better-sqlite3', 'keytar'],
|
||||||
output: {
|
output: {
|
||||||
entryFileNames: 'main.js',
|
entryFileNames: 'main.js',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user