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:
26
src/main/ai/copilot.ts
Normal file
26
src/main/ai/copilot.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { registerProvider, type AIProvider } from './provider';
|
||||
|
||||
let token: string | null = null;
|
||||
|
||||
const copilotProvider: AIProvider = {
|
||||
name: 'copilot',
|
||||
displayName: 'GitHub Copilot',
|
||||
|
||||
async initialize(t: string): Promise<boolean> {
|
||||
token = t;
|
||||
// Actual GitHub Copilot SDK client creation will be added in US-019.
|
||||
// For now, having a token means the provider is ready.
|
||||
return true;
|
||||
},
|
||||
|
||||
isReady(): boolean {
|
||||
return token !== null;
|
||||
},
|
||||
};
|
||||
|
||||
/** Get the raw Copilot token (used by future chat/completion calls). */
|
||||
export function getCopilotToken(): string | null {
|
||||
return token;
|
||||
}
|
||||
|
||||
registerProvider(copilotProvider);
|
||||
80
src/main/ai/provider.ts
Normal file
80
src/main/ai/provider.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { getStore } from '../store';
|
||||
import { getToken, setToken as storeToken } from './token';
|
||||
|
||||
export interface AIProvider {
|
||||
/** Internal key, e.g. 'copilot', 'openai', 'anthropic' */
|
||||
name: string;
|
||||
/** Human-readable label shown in Settings UI */
|
||||
displayName: string;
|
||||
/** Initialize with a token. Returns true if the provider is ready. */
|
||||
initialize(token: string): Promise<boolean>;
|
||||
/** Whether the provider is initialized and ready to handle requests. */
|
||||
isReady(): boolean;
|
||||
}
|
||||
|
||||
const providers = new Map<string, AIProvider>();
|
||||
let activeProvider: AIProvider | null = null;
|
||||
|
||||
/** Register a provider implementation. Call at import time. */
|
||||
export function registerProvider(provider: AIProvider): void {
|
||||
providers.set(provider.name, provider);
|
||||
}
|
||||
|
||||
/** Get the currently active provider (may be null if none configured). */
|
||||
export function getActiveProvider(): AIProvider | null {
|
||||
return activeProvider;
|
||||
}
|
||||
|
||||
/** Get the active provider's name from electron-store. */
|
||||
export function getActiveProviderName(): string {
|
||||
return getStore().get('aiProvider');
|
||||
}
|
||||
|
||||
/** Switch to a different registered provider. */
|
||||
export function setActiveProviderName(name: string): void {
|
||||
const provider = providers.get(name);
|
||||
if (!provider) throw new Error(`Unknown AI provider: ${name}`);
|
||||
activeProvider = provider;
|
||||
getStore().set('aiProvider', name);
|
||||
}
|
||||
|
||||
/** Store token for the active provider and re-initialize it. */
|
||||
export async function saveTokenAndInit(token: string): Promise<void> {
|
||||
const name = getActiveProviderName();
|
||||
await storeToken(name, token);
|
||||
const provider = providers.get(name);
|
||||
if (provider) {
|
||||
await provider.initialize(token);
|
||||
activeProvider = provider;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check whether the active provider has a stored token. */
|
||||
export async function hasActiveToken(): Promise<boolean> {
|
||||
const name = getActiveProviderName();
|
||||
const token = await getToken(name);
|
||||
return token !== null && token.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the AI subsystem on app startup.
|
||||
* Reads the active provider from settings, loads its token from keychain,
|
||||
* and calls provider.initialize() if a token exists.
|
||||
*/
|
||||
export async function initAI(): Promise<void> {
|
||||
const name = getActiveProviderName();
|
||||
const provider = providers.get(name);
|
||||
if (!provider) {
|
||||
console.log(`[AI] No provider registered for "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await getToken(name);
|
||||
if (token) {
|
||||
const ready = await provider.initialize(token);
|
||||
activeProvider = provider;
|
||||
console.log(`[AI] Provider "${provider.displayName}" initialized: ready=${ready}`);
|
||||
} else {
|
||||
console.log(`[AI] No token stored for provider "${provider.displayName}"`);
|
||||
}
|
||||
}
|
||||
114
src/main/ai/token.ts
Normal file
114
src/main/ai/token.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { safeStorage } from 'electron';
|
||||
import { getStore } from '../store';
|
||||
|
||||
/**
|
||||
* Token storage with three-tier fallback:
|
||||
* 1. OS keychain via keytar (best — encrypted, per-user)
|
||||
* 2. Electron safeStorage + electron-store (encrypted at rest)
|
||||
* 3. Plain electron-store (last resort — e.g. WSL with no keyring)
|
||||
*/
|
||||
|
||||
let keytar: typeof import('keytar') | null = null;
|
||||
let keytarFailed = false;
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
keytar = require('keytar') as typeof import('keytar');
|
||||
} catch {
|
||||
keytarFailed = true;
|
||||
console.log('[Token] keytar native module unavailable');
|
||||
}
|
||||
|
||||
function useKeytar(): boolean {
|
||||
return keytar !== null && !keytarFailed;
|
||||
}
|
||||
|
||||
function canUseSafeStorage(): boolean {
|
||||
try {
|
||||
return safeStorage.isEncryptionAvailable();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const SERVICE_NAME = 'adiuva';
|
||||
|
||||
// --- electron-store helpers (with optional safeStorage encryption) ---
|
||||
|
||||
function readFromStore(providerName: string): string | null {
|
||||
const tokens = getStore().get('encryptedTokens');
|
||||
const stored = tokens[providerName];
|
||||
if (!stored) return null;
|
||||
|
||||
if (canUseSafeStorage()) {
|
||||
try {
|
||||
return safeStorage.decryptString(Buffer.from(stored, 'base64'));
|
||||
} catch {
|
||||
// Stored value might be plaintext from a previous fallback
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
// No encryption available — value is stored as plaintext
|
||||
return stored;
|
||||
}
|
||||
|
||||
function writeToStore(providerName: string, token: string): void {
|
||||
let value: string;
|
||||
if (canUseSafeStorage()) {
|
||||
value = safeStorage.encryptString(token).toString('base64');
|
||||
} else {
|
||||
// Last resort: store plaintext (WSL with no keyring)
|
||||
value = token;
|
||||
}
|
||||
const tokens = getStore().get('encryptedTokens');
|
||||
getStore().set('encryptedTokens', { ...tokens, [providerName]: value });
|
||||
}
|
||||
|
||||
function removeFromStore(providerName: string): void {
|
||||
const tokens = getStore().get('encryptedTokens');
|
||||
const { [providerName]: _, ...rest } = tokens;
|
||||
getStore().set('encryptedTokens', rest);
|
||||
}
|
||||
|
||||
// --- public API ---
|
||||
|
||||
/** Read a stored token for the given provider. */
|
||||
export async function getToken(providerName: string): Promise<string | null> {
|
||||
if (useKeytar()) {
|
||||
try {
|
||||
return await keytar!.getPassword(SERVICE_NAME, providerName);
|
||||
} catch (err) {
|
||||
console.log('[Token] keytar runtime error, falling back:', (err as Error).message);
|
||||
keytarFailed = true;
|
||||
}
|
||||
}
|
||||
return readFromStore(providerName);
|
||||
}
|
||||
|
||||
/** Store a token for the given provider. */
|
||||
export async function setToken(providerName: string, token: string): Promise<void> {
|
||||
if (useKeytar()) {
|
||||
try {
|
||||
await keytar!.setPassword(SERVICE_NAME, providerName, token);
|
||||
return;
|
||||
} catch (err) {
|
||||
console.log('[Token] keytar runtime error, falling back:', (err as Error).message);
|
||||
keytarFailed = true;
|
||||
}
|
||||
}
|
||||
writeToStore(providerName, token);
|
||||
}
|
||||
|
||||
/** Delete a stored token for the given provider. */
|
||||
export async function deleteToken(providerName: string): Promise<boolean> {
|
||||
if (useKeytar()) {
|
||||
try {
|
||||
return await keytar!.deletePassword(SERVICE_NAME, providerName);
|
||||
} catch (err) {
|
||||
console.log('[Token] keytar runtime error, falling back:', (err as Error).message);
|
||||
keytarFailed = true;
|
||||
}
|
||||
}
|
||||
removeFromStore(providerName);
|
||||
return true;
|
||||
}
|
||||
@@ -53,6 +53,14 @@ const MIGRATION_SQL = `
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
author TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
type DbInstance = ReturnType<typeof drizzle<typeof schema>>;
|
||||
|
||||
@@ -49,6 +49,14 @@ export const notes = sqliteTable('notes', {
|
||||
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
export const taskComments = sqliteTable('task_comments', {
|
||||
id: text('id').primaryKey(),
|
||||
taskId: text('task_id').notNull(),
|
||||
author: text('author').notNull(),
|
||||
content: text('content').notNull(),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
// Inferred TypeScript types — no manual duplication
|
||||
export type Client = InferSelectModel<typeof clients>;
|
||||
export type NewClient = InferInsertModel<typeof clients>;
|
||||
@@ -64,3 +72,6 @@ export type NewCheckpoint = InferInsertModel<typeof checkpoints>;
|
||||
|
||||
export type Note = InferSelectModel<typeof notes>;
|
||||
export type NewNote = InferInsertModel<typeof notes>;
|
||||
|
||||
export type TaskComment = InferSelectModel<typeof taskComments>;
|
||||
export type NewTaskComment = InferInsertModel<typeof taskComments>;
|
||||
|
||||
@@ -4,6 +4,9 @@ import started from 'electron-squirrel-startup';
|
||||
import { initDb } from './db';
|
||||
import { appRouter } from './router';
|
||||
import { createIPCHandler } from './ipc';
|
||||
import { initAI } from './ai/provider';
|
||||
// Import to trigger provider registration before initAI() runs
|
||||
import './ai/copilot';
|
||||
|
||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||
if (started) {
|
||||
@@ -49,6 +52,8 @@ app.on('ready', () => {
|
||||
initDb();
|
||||
const win = createWindow();
|
||||
createIPCHandler({ router: appRouter, windows: [win] });
|
||||
// AI init is best-effort — never block window creation
|
||||
initAI().catch((err) => console.error('[AI] Init failed:', err));
|
||||
});
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
|
||||
@@ -3,8 +3,9 @@ import { z } from 'zod';
|
||||
import { eq, asc, inArray, and, or, like, sql } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/sqlite-core';
|
||||
import { getDb } from '../db';
|
||||
import { clients, projects, tasks, checkpoints, notes } from '../db/schema';
|
||||
import { clients, projects, tasks, checkpoints, notes, taskComments } from '../db/schema';
|
||||
import { getStore } from '../store';
|
||||
import { saveTokenAndInit, hasActiveToken } from '../ai/provider';
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
@@ -199,12 +200,13 @@ const tasksRouter = router({
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const orderByClause =
|
||||
const priorityExpr = sql`CASE ${tasks.priority} WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`;
|
||||
const orderByClauses =
|
||||
input?.orderBy === 'dueDate'
|
||||
? asc(tasks.dueDate)
|
||||
? [asc(tasks.dueDate), asc(priorityExpr)]
|
||||
: input?.orderBy === 'priority'
|
||||
? asc(sql`CASE ${tasks.priority} WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`)
|
||||
: asc(tasks.createdAt);
|
||||
? [asc(priorityExpr), asc(tasks.dueDate)]
|
||||
: [asc(tasks.dueDate), asc(priorityExpr)];
|
||||
|
||||
return db
|
||||
.select({
|
||||
@@ -226,7 +228,7 @@ const tasksRouter = router({
|
||||
.leftJoin(clients, eq(projects.clientId, clients.id))
|
||||
.leftJoin(parentClients, eq(clients.parentId, parentClients.id))
|
||||
.where(conditions)
|
||||
.orderBy(orderByClause)
|
||||
.orderBy(...orderByClauses)
|
||||
.all();
|
||||
}),
|
||||
|
||||
@@ -436,6 +438,41 @@ const notesRouter = router({
|
||||
}),
|
||||
});
|
||||
|
||||
const taskCommentsRouter = router({
|
||||
list: publicProcedure
|
||||
.input(z.object({ taskId: z.string() }))
|
||||
.query(({ input }) => {
|
||||
return getDb()
|
||||
.select()
|
||||
.from(taskComments)
|
||||
.where(eq(taskComments.taskId, input.taskId))
|
||||
.orderBy(asc(taskComments.createdAt))
|
||||
.all();
|
||||
}),
|
||||
|
||||
create: publicProcedure
|
||||
.input(z.object({ taskId: z.string(), author: z.string(), content: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
getDb().insert(taskComments).values({
|
||||
id,
|
||||
taskId: input.taskId,
|
||||
author: input.author,
|
||||
content: input.content,
|
||||
createdAt: now,
|
||||
}).run();
|
||||
return { id, taskId: input.taskId, author: input.author, content: input.content, createdAt: now };
|
||||
}),
|
||||
|
||||
delete: publicProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
getDb().delete(taskComments).where(eq(taskComments.id, input.id)).run();
|
||||
return { success: true as const };
|
||||
}),
|
||||
});
|
||||
|
||||
const settingsRouter = router({
|
||||
getSidebarCollapsed: publicProcedure.query(() => getStore().get('sidebarCollapsed')),
|
||||
setSidebarCollapsed: publicProcedure
|
||||
@@ -458,8 +495,13 @@ const aiRouter = router({
|
||||
.mutation(() => ({ response: '' })),
|
||||
setToken: publicProcedure
|
||||
.input(z.object({ token: z.string() }))
|
||||
.mutation(() => null),
|
||||
hasToken: publicProcedure.query(() => false),
|
||||
.mutation(async ({ input }) => {
|
||||
await saveTokenAndInit(input.token);
|
||||
return { success: true };
|
||||
}),
|
||||
hasToken: publicProcedure.query(async () => {
|
||||
return hasActiveToken();
|
||||
}),
|
||||
});
|
||||
|
||||
export const appRouter = router({
|
||||
@@ -470,6 +512,7 @@ export const appRouter = router({
|
||||
tasks: tasksRouter,
|
||||
checkpoints: checkpointsRouter,
|
||||
notes: notesRouter,
|
||||
taskComments: taskCommentsRouter,
|
||||
ai: aiRouter,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import Store from 'electron-store';
|
||||
|
||||
interface AppSettings {
|
||||
sidebarCollapsed: boolean;
|
||||
aiProvider: string;
|
||||
encryptedTokens: Record<string, string>;
|
||||
}
|
||||
|
||||
let _store: Store<AppSettings> | null = null;
|
||||
@@ -11,6 +13,8 @@ export function getStore(): Store<AppSettings> {
|
||||
_store = new Store<AppSettings>({
|
||||
defaults: {
|
||||
sidebarCollapsed: false,
|
||||
aiProvider: 'copilot',
|
||||
encryptedTokens: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user