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

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