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
{
"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
View File

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

View File

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

View File

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

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
- `{ 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,
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>>;

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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;
}
/* 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);
}

View File

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

View File

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

View File

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

View File

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