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
|
||||
{
|
||||
"id": "US-017",
|
||||
"title": "Fluid Curtain pull-down animation",
|
||||
"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.",
|
||||
"id": "US-018",
|
||||
"title": "GitHub Copilot SDK setup and keytar token storage",
|
||||
"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": [
|
||||
"framer-motion useMotionValue + useSpring (stiffness: 300, damping: 30) controls a 'y' CSS transform on the main app panel wrapper",
|
||||
"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",
|
||||
"Trigger 2: Cmd/Ctrl+K keyboard shortcut toggles curtain open (y = viewport height) and closed (y = 0)",
|
||||
"AI chat view is rendered as a fixed full-screen layer behind the sliding panel and becomes fully visible when panel slides down",
|
||||
"App panel remains mounted during animation (no unmount/remount, no state loss)",
|
||||
"Returning from chat: wheel event with deltaY > 0 at chat-bottom OR Cmd/Ctrl+K slides panel back to y = 0",
|
||||
"Right-edge vertical 'keep scrolling for AI' label with chevron-down is visible in every section (non-interactive, visual hint only)",
|
||||
"Typecheck passes",
|
||||
"Verify in browser using dev-browser skill"
|
||||
"keytar installed and imported in main process only (not renderer)",
|
||||
"ai.setToken tRPC mutation accepts { token: string } and stores it via keytar.setPassword('adiuva', 'copilot-token', token)",
|
||||
"ai.hasToken tRPC query returns a boolean indicating whether a token is stored",
|
||||
"On app start, main process reads the token from keychain and initializes the GitHub Copilot SDK client",
|
||||
"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",
|
||||
"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",
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 17,
|
||||
"priority": 18,
|
||||
"passes": false,
|
||||
"notes": ""
|
||||
}
|
||||
47
package-lock.json
generated
47
package-lock.json
generated
@@ -11,9 +11,8 @@
|
||||
"dependencies": {
|
||||
"@fontsource/geist": "^5.2.8",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@milkdown/crepe": "^7.18.0",
|
||||
"@milkdown/kit": "^7.18.0",
|
||||
"@milkdown/react": "^7.18.0",
|
||||
"@milkdown/theme-nord": "^7.18.0",
|
||||
"@tailwindcss/vite": "^4.2.0",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-router": "^1.161.1",
|
||||
@@ -28,6 +27,7 @@
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"electron-store": "^8.2.0",
|
||||
"framer-motion": "^12.34.2",
|
||||
"keytar": "^7.9.0",
|
||||
"lucide-react": "^0.575.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
@@ -4156,32 +4156,6 @@
|
||||
"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": {
|
||||
"version": "7.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@milkdown/transformer/-/transformer-7.18.0.tgz",
|
||||
@@ -14777,6 +14751,17 @@
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/keytar": {
|
||||
"version": "7.9.0",
|
||||
"resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz",
|
||||
"integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^4.3.0",
|
||||
"prebuild-install": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -16761,6 +16746,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz",
|
||||
"integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-api-version": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz",
|
||||
|
||||
@@ -47,9 +47,8 @@
|
||||
"dependencies": {
|
||||
"@fontsource/geist": "^5.2.8",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@milkdown/crepe": "^7.18.0",
|
||||
"@milkdown/kit": "^7.18.0",
|
||||
"@milkdown/react": "^7.18.0",
|
||||
"@milkdown/theme-nord": "^7.18.0",
|
||||
"@tailwindcss/vite": "^4.2.0",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-router": "^1.161.1",
|
||||
@@ -64,6 +63,7 @@
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"electron-store": "^8.2.0",
|
||||
"framer-motion": "^12.34.2",
|
||||
"keytar": "^7.9.0",
|
||||
"lucide-react": "^0.575.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
|
||||
4
prd.json
4
prd.json
@@ -333,8 +333,8 @@
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 18,
|
||||
"passes": false,
|
||||
"notes": ""
|
||||
"passes": true,
|
||||
"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",
|
||||
|
||||
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
|
||||
- `{ 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,
|
||||
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>>;
|
||||
|
||||
@@ -49,6 +49,14 @@ export const notes = sqliteTable('notes', {
|
||||
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
|
||||
export type Client = InferSelectModel<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 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 { appRouter } from './router';
|
||||
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.
|
||||
if (started) {
|
||||
@@ -49,6 +52,8 @@ app.on('ready', () => {
|
||||
initDb();
|
||||
const win = createWindow();
|
||||
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
|
||||
|
||||
@@ -3,8 +3,9 @@ import { z } from 'zod';
|
||||
import { eq, asc, inArray, and, or, like, sql } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/sqlite-core';
|
||||
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 { saveTokenAndInit, hasActiveToken } from '../ai/provider';
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
@@ -199,12 +200,13 @@ const tasksRouter = router({
|
||||
: 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'
|
||||
? asc(tasks.dueDate)
|
||||
? [asc(tasks.dueDate), asc(priorityExpr)]
|
||||
: input?.orderBy === 'priority'
|
||||
? asc(sql`CASE ${tasks.priority} WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`)
|
||||
: asc(tasks.createdAt);
|
||||
? [asc(priorityExpr), asc(tasks.dueDate)]
|
||||
: [asc(tasks.dueDate), asc(priorityExpr)];
|
||||
|
||||
return db
|
||||
.select({
|
||||
@@ -226,7 +228,7 @@ const tasksRouter = router({
|
||||
.leftJoin(clients, eq(projects.clientId, clients.id))
|
||||
.leftJoin(parentClients, eq(clients.parentId, parentClients.id))
|
||||
.where(conditions)
|
||||
.orderBy(orderByClause)
|
||||
.orderBy(...orderByClauses)
|
||||
.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({
|
||||
getSidebarCollapsed: publicProcedure.query(() => getStore().get('sidebarCollapsed')),
|
||||
setSidebarCollapsed: publicProcedure
|
||||
@@ -458,8 +495,13 @@ const aiRouter = router({
|
||||
.mutation(() => ({ response: '' })),
|
||||
setToken: publicProcedure
|
||||
.input(z.object({ token: z.string() }))
|
||||
.mutation(() => null),
|
||||
hasToken: publicProcedure.query(() => false),
|
||||
.mutation(async ({ input }) => {
|
||||
await saveTokenAndInit(input.token);
|
||||
return { success: true };
|
||||
}),
|
||||
hasToken: publicProcedure.query(async () => {
|
||||
return hasActiveToken();
|
||||
}),
|
||||
});
|
||||
|
||||
export const appRouter = router({
|
||||
@@ -470,6 +512,7 @@ export const appRouter = router({
|
||||
tasks: tasksRouter,
|
||||
checkpoints: checkpointsRouter,
|
||||
notes: notesRouter,
|
||||
taskComments: taskCommentsRouter,
|
||||
ai: aiRouter,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import Store from 'electron-store';
|
||||
|
||||
interface AppSettings {
|
||||
sidebarCollapsed: boolean;
|
||||
aiProvider: string;
|
||||
encryptedTokens: Record<string, string>;
|
||||
}
|
||||
|
||||
let _store: Store<AppSettings> | null = null;
|
||||
@@ -11,6 +13,8 @@ export function getStore(): Store<AppSettings> {
|
||||
_store = new Store<AppSettings>({
|
||||
defaults: {
|
||||
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 (
|
||||
<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" />
|
||||
|
||||
@@ -9,6 +9,13 @@ import {
|
||||
PanelLeft,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Check,
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
Palette
|
||||
} from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import {
|
||||
@@ -25,7 +32,30 @@ import {
|
||||
SidebarProvider,
|
||||
useSidebar,
|
||||
} 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 { useTheme } from '@/components/theme-provider';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/', icon: House, label: 'Home' },
|
||||
@@ -72,6 +102,21 @@ export function AppShell({ children }: AppShellProps) {
|
||||
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
|
||||
const searchObj = routerState.location.search as Record<string, unknown>;
|
||||
const curtainEnabled =
|
||||
@@ -141,11 +186,15 @@ export function AppShell({ children }: AppShellProps) {
|
||||
}, [openCurtain, closeCurtain]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarProvider open={open} onOpenChange={handleOpenChange}>
|
||||
<AppSidebar currentPath={currentPath} />
|
||||
<AppSidebar
|
||||
currentPath={currentPath}
|
||||
setTokenDialogOpen={setTokenDialogOpen}
|
||||
/>
|
||||
<SidebarInset className="overflow-hidden">
|
||||
{/* AI Chat layer: always mounted behind the content panel */}
|
||||
<AIChatPanel />
|
||||
<AIChatPanel onOpenSettings={() => setTokenDialogOpen(true)} />
|
||||
|
||||
{/* Content panel: slides down to reveal chat */}
|
||||
<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="flex flex-col items-center gap-1.5 pr-2">
|
||||
{curtainOpen ? (
|
||||
<ChevronDown size={10} className="text-muted-foreground/30" />
|
||||
<ChevronDown size={10} />
|
||||
) : (
|
||||
<ChevronUp size={10} className="text-muted-foreground/30" />
|
||||
<ChevronUp size={10} />
|
||||
)}
|
||||
<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)' }}
|
||||
>
|
||||
{curtainOpen ? 'back to app' : 'scrolling up for Adiuva'}
|
||||
@@ -173,11 +222,62 @@ export function AppShell({ children }: AppShellProps) {
|
||||
</motion.div>
|
||||
</SidebarInset>
|
||||
</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 { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
@@ -241,9 +341,50 @@ function AppSidebar({ currentPath }: { currentPath: string }) {
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
{/* Collapse toggle — spec: useSidebar() + custom trigger */}
|
||||
{/* Settings gear + Collapse toggle */}
|
||||
<SidebarFooter>
|
||||
<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>
|
||||
<SidebarMenuButton onClick={toggleSidebar} tooltip="Toggle Sidebar">
|
||||
<PanelLeft />
|
||||
@@ -252,6 +393,7 @@ function AppSidebar({ currentPath }: { currentPath: string }) {
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,49 @@
|
||||
import { useRef } from 'react';
|
||||
import { Editor, rootCtx, defaultValueCtx } from '@milkdown/kit/core';
|
||||
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 { useEffect, useRef } from 'react';
|
||||
import { Crepe, CrepeFeature } from '@milkdown/crepe';
|
||||
|
||||
import '@milkdown/kit/prose/view/style/prosemirror.css';
|
||||
import '@milkdown/crepe/theme/common/style.css';
|
||||
import '@milkdown/crepe/theme/nord.css';
|
||||
|
||||
interface MilkdownEditorProps {
|
||||
initialContent: string;
|
||||
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);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
useEditor((root) =>
|
||||
Editor.make()
|
||||
.config((ctx) => {
|
||||
ctx.set(rootCtx, root);
|
||||
ctx.set(defaultValueCtx, initialContent);
|
||||
})
|
||||
.use(commonmark)
|
||||
.use(history)
|
||||
.use(listener)
|
||||
.config((ctx) => {
|
||||
ctx.get(listenerCtx).markdownUpdated((_ctx, markdown, prevMarkdown) => {
|
||||
if (markdown !== prevMarkdown) {
|
||||
onChangeRef.current(markdown);
|
||||
}
|
||||
});
|
||||
}),
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
return <Milkdown />;
|
||||
}
|
||||
const crepe = new Crepe({
|
||||
root: containerRef.current,
|
||||
defaultValue: initialContent,
|
||||
featureConfigs: {
|
||||
[CrepeFeature.Placeholder]: {
|
||||
text: 'Start writing...',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function MilkdownEditor(props: MilkdownEditorProps) {
|
||||
return (
|
||||
<MilkdownProvider>
|
||||
<MilkdownInner {...props} />
|
||||
</MilkdownProvider>
|
||||
);
|
||||
crepe.on((listener) => {
|
||||
listener.markdownUpdated((_ctx, markdown, prevMarkdown) => {
|
||||
if (markdown !== prevMarkdown) {
|
||||
onChangeRef.current(markdown);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
crepe.create();
|
||||
crepeRef.current = crepe;
|
||||
|
||||
return () => {
|
||||
crepe.destroy();
|
||||
crepeRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
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 {
|
||||
const d = new Date(timestamp);
|
||||
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({
|
||||
@@ -49,12 +53,14 @@ export function TaskRow({
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onClick,
|
||||
hideBreadcrumb,
|
||||
}: {
|
||||
task: TaskItem;
|
||||
onToggle: (id: string, status: string | null) => void;
|
||||
onEdit?: (task: TaskItem) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
onClick?: (task: TaskItem) => void;
|
||||
hideBreadcrumb?: boolean;
|
||||
}) {
|
||||
const isDone = task.status === 'done';
|
||||
@@ -80,15 +86,17 @@ export function TaskRow({
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={`flex flex-col gap-1.5 px-4 py-3 rounded-md border cursor-default select-none ${
|
||||
isDone ? 'bg-green-50 border-green-200' : 'bg-white border-border'
|
||||
}`}
|
||||
className={`flex flex-col gap-1.5 px-4 py-3 rounded-md border select-none transition-colors ${
|
||||
isDone ? 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900' : 'bg-card border-border'
|
||||
} ${onClick ? 'cursor-pointer hover:bg-accent/50' : 'cursor-default'}`}
|
||||
onClick={() => onClick?.(task)}
|
||||
>
|
||||
{/* Row 1: checkbox + title + description */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={checkboxState}
|
||||
onCheckedChange={() => onToggle(task.id, task.status)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mt-0.5 shrink-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;
|
||||
}
|
||||
|
||||
/* Milkdown editor overrides */
|
||||
[data-milkdown-root] {
|
||||
/* Crepe editor layout */
|
||||
.milkdown-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.milkdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.milkdown-container .milkdown {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
color: var(--foreground);
|
||||
font-family: inherit;
|
||||
line-height: 1.75;
|
||||
--crepe-color-background: var(--background);
|
||||
--crepe-font-default: 'Geist', 'Inter', system-ui, sans-serif;
|
||||
--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;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.milkdown .editor > * + * {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
|
||||
.milkdown .editor h1 {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.milkdown .editor h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.milkdown .editor h3 {
|
||||
font-size: 1.25rem;
|
||||
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;
|
||||
/* Dark theme: scope nord-dark variables under .dark class */
|
||||
.dark .milkdown {
|
||||
--crepe-color-on-background: #f8f9ff;
|
||||
--crepe-color-surface: #111418;
|
||||
--crepe-color-surface-low: #191c20;
|
||||
--crepe-color-on-surface: #e1e2e8;
|
||||
--crepe-color-on-surface-variant: #c3c6cf;
|
||||
--crepe-color-outline: #8d9199;
|
||||
--crepe-color-primary: #a1c9fd;
|
||||
--crepe-color-secondary: #3c4858;
|
||||
--crepe-color-on-secondary: #d7e3f8;
|
||||
--crepe-color-inverse: #e1e2e8;
|
||||
--crepe-color-on-inverse: #2e3135;
|
||||
--crepe-color-inline-code: #ffb4ab;
|
||||
--crepe-color-error: #ffb4ab;
|
||||
--crepe-color-hover: #1d2024;
|
||||
--crepe-color-selected: #32353a;
|
||||
--crepe-color-inline-area: #111418;
|
||||
--crepe-shadow-1: 0px 1px 2px 0px rgba(255, 255, 255, 0.3), 0px 1px 3px 1px rgba(255, 255, 255, 0.15);
|
||||
--crepe-shadow-2: 0px 1px 2px 0px rgba(255, 255, 255, 0.3), 0px 2px 6px 2px rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ipcLink } from './lib/ipcLink';
|
||||
import { router } from './router';
|
||||
import { trpc } from './lib/trpc';
|
||||
import { ThemeProvider } from './components/theme-provider';
|
||||
import './globals.css';
|
||||
|
||||
function App() {
|
||||
@@ -16,11 +17,13 @@ function App() {
|
||||
);
|
||||
|
||||
return (
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</trpc.Provider>
|
||||
<ThemeProvider defaultTheme="system" storageKey="adiuva-theme">
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</trpc.Provider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
|
||||
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
|
||||
import { EditTaskDialog } from '@/components/tasks/EditTaskDialog';
|
||||
import { TaskDetailDialog } from '@/components/tasks/TaskDetailDialog';
|
||||
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
|
||||
|
||||
export const Route = createFileRoute('/tasks')({
|
||||
@@ -42,9 +43,10 @@ function TasksPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
const [orderBy, setOrderBy] = useState<OrderBy>('createdAt');
|
||||
const [orderBy, setOrderBy] = useState<OrderBy>('dueDate');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editTask, setEditTask] = useState<TaskItem | null>(null);
|
||||
const [viewTask, setViewTask] = useState<TaskItem | null>(null);
|
||||
|
||||
const debounceTimer = useMemo(() => ({ id: null as ReturnType<typeof setTimeout> | null }), []);
|
||||
|
||||
@@ -127,7 +129,7 @@ function TasksPage() {
|
||||
<ItemDescription>To Do</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
<Item variant="muted" className="bg-sky-50">
|
||||
<Item variant="muted" className="bg-sky-50 dark:bg-sky-950/30">
|
||||
<ItemMedia variant="icon">
|
||||
<Loader2 />
|
||||
</ItemMedia>
|
||||
@@ -136,7 +138,7 @@ function TasksPage() {
|
||||
<ItemDescription>In Progress</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
<Item variant="muted" className="bg-green-50">
|
||||
<Item variant="muted" className="bg-green-50 dark:bg-green-950/30">
|
||||
<ItemMedia variant="icon">
|
||||
<CheckCircle2 />
|
||||
</ItemMedia>
|
||||
@@ -211,6 +213,7 @@ function TasksPage() {
|
||||
onToggle={handleCheckboxToggle}
|
||||
onEdit={setEditTask}
|
||||
onDelete={(id) => deleteTask.mutate({ id })}
|
||||
onClick={setViewTask}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -222,6 +225,13 @@ function TasksPage() {
|
||||
open={!!editTask}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ function TimelinePage() {
|
||||
No checkpoints yet. Click "+ Add" to create your first milestone.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md p-4 bg-white">
|
||||
<div className="border rounded-md p-4 bg-card">
|
||||
<GanttChart
|
||||
checkpoints={ganttCheckpoints}
|
||||
startDate={startDate}
|
||||
|
||||
@@ -5,7 +5,7 @@ export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
// Externalize native Node modules — they're rebuilt by electron-forge
|
||||
external: ['better-sqlite3'],
|
||||
external: ['better-sqlite3', 'keytar'],
|
||||
output: {
|
||||
entryFileNames: 'main.js',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user