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:
Roberto Musso
2026-02-23 12:54:14 +01:00
parent 98acf6220e
commit c1aa6829c9
24 changed files with 996 additions and 234 deletions

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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
View 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
View 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
View 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;
}

View File

@@ -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>>;

View File

@@ -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>;

View File

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

View File

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

View File

@@ -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: {},
}, },
}); });
} }

View File

@@ -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" />

View File

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

View File

@@ -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);
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);
}
});
}),
[]
);
return <Milkdown />; const crepe = new Crepe({
} root: containerRef.current,
defaultValue: initialContent,
featureConfigs: {
[CrepeFeature.Placeholder]: {
text: 'Start writing...',
},
},
});
export function MilkdownEditor(props: MilkdownEditorProps) { crepe.on((listener) => {
return ( listener.markdownUpdated((_ctx, markdown, prevMarkdown) => {
<MilkdownProvider> if (markdown !== prevMarkdown) {
<MilkdownInner {...props} /> onChangeRef.current(markdown);
</MilkdownProvider> }
); });
});
crepe.create();
crepeRef.current = crepe;
return () => {
crepe.destroy();
crepeRef.current = null;
};
}, []);
return <div ref={containerRef} className="milkdown-container" />;
} }

View 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>
);
}

View File

@@ -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">

View 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
}

View File

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

View File

@@ -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 (
<trpc.Provider client={trpcClient} queryClient={queryClient}> <ThemeProvider defaultTheme="system" storageKey="adiuva-theme">
<QueryClientProvider client={queryClient}> <trpc.Provider client={trpcClient} queryClient={queryClient}>
<RouterProvider router={router} /> <QueryClientProvider client={queryClient}>
</QueryClientProvider> <RouterProvider router={router} />
</trpc.Provider> </QueryClientProvider>
</trpc.Provider>
</ThemeProvider>
); );
} }

View File

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

View File

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

View File

@@ -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',
}, },