sync: multiple features from private repo

- quick-add-loading: fire-and-forget with loading placeholders, auto-open feed, SSE events, QuickAddInput redesign
- database-table-pane: TablePane + DatabaseTableView, countNodes(), event_date sort
- ui-polish-fixes: focus tab order, dim name editing, tab title sync, cost chip removal, custom sort drag-reorder, refresh button, dimension filter removal (~2,200 lines)
- node-creation-quality: description service prompt rewrite, title sanitization, extraction tool AI prompt rewrites
- feed-pane-ux: stripped kanban/grid/saved views, sort dropdown, AND dimension filtering, description preview

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
“BeeRad”
2026-02-15 17:57:32 +11:00
co-authored by Claude Opus 4.6
parent 9954792b1d
commit 2f6518207d
25 changed files with 1590 additions and 2647 deletions
+41 -34
View File
@@ -51,16 +51,6 @@ function buildTaskPrompt(type: QuickAddInputType, input: string): string {
}
}
function buildExpectedOutcome(type: QuickAddInputType): string {
if (type === 'note') {
return 'Call createNode with the user input as content. Use a descriptive title extracted from the first few words. Set dimensions to an empty array. Return a brief confirmation like "Created note [NODE:id:title]"';
}
if (type === 'chat') {
return 'Store the entire transcript in chunk_content, summarize the conversation into content, and return a confirmation like "Created chat transcript node [NODE:id:title]"';
}
return 'Use the appropriate extraction tool (youtubeExtract, websiteExtract, or paperExtract) to create the node. Return the standard extraction summary.';
}
type ExtractionQuickAddType = Extract<QuickAddInputType, 'youtube' | 'website' | 'pdf'>;
const EXTRACTION_TOOL_MAP = {
@@ -207,7 +197,7 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
const title = deriveNoteTitle(content);
const nodePayload: Record<string, unknown> = {
title,
content,
notes: content,
metadata: {
source: 'quick-add-note',
refined_at: new Date().toISOString(),
@@ -312,7 +302,7 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Pro
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
content,
notes: content,
chunk: transcript,
metadata,
}),
@@ -341,41 +331,58 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Pro
}
export interface QuickAddResult {
success: boolean;
id: string;
task: string;
inputType: QuickAddInputType;
status: 'queued' | 'completed' | 'failed';
summary?: string;
error?: string;
nodeId?: number;
}
export async function enqueueQuickAdd({ rawInput, mode, description }: QuickAddInput): Promise<QuickAddResult> {
const inputType = detectInputType(rawInput, mode);
const task = buildTaskPrompt(inputType, rawInput);
const id = `qa_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
try {
let summary: string;
if (inputType === 'note') {
summary = await handleNoteQuickAdd(rawInput, task, description);
} else if (inputType === 'chat') {
summary = await handleChatTranscriptQuickAdd(rawInput, task);
} else {
summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task);
}
const result: QuickAddResult = {
id,
task,
inputType,
status: 'queued',
};
// Extract node ID from summary if present
const nodeIdMatch = summary.match(/\[NODE:(\d+)/);
const nodeId = nodeIdMatch ? parseInt(nodeIdMatch[1], 10) : undefined;
// Run async - fire and forget
setImmediate(async () => {
try {
let summary: string;
if (inputType === 'note') {
summary = await handleNoteQuickAdd(rawInput, task, description);
} else if (inputType === 'chat') {
summary = await handleChatTranscriptQuickAdd(rawInput, task);
} else {
summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task);
}
// Broadcast node created event
if (nodeId) {
console.log(`[QuickAdd] Completed: ${task}`);
// Broadcast completion so ThreePanelLayout can remove the pending placeholder
eventBroadcaster.broadcast({
type: 'QUICK_ADD_COMPLETED',
data: { quickAddId: id, source: 'quick-add' }
});
// Also broadcast NODE_CREATED to refresh the feed
eventBroadcaster.broadcast({
type: 'NODE_CREATED',
data: { node: { id: nodeId, title: 'Quick Add' } }
data: { node: { title: task }, source: 'quick-add' }
});
} catch (error) {
const message = error instanceof Error ? error.message : 'unknown error';
console.error(`[QuickAdd] Failed: ${task} - ${message}`);
eventBroadcaster.broadcast({
type: 'QUICK_ADD_FAILED',
data: { quickAddId: id, error: message, source: 'quick-add' }
});
}
});
return { success: true, summary, nodeId };
} catch (error) {
const message = error instanceof Error ? error.message : 'unknown error';
return { success: false, error: `Quick Add failed: ${message}` };
}
return result;
}
+16 -12
View File
@@ -40,7 +40,7 @@ export function generateFallbackDescription(input: DescriptionInput): string {
return `${parts.join(' — ')}: ${title.slice(0, 200)}`;
}
return `Knowledge item: ${title.slice(0, 250)}`;
return title.slice(0, 280);
}
/**
@@ -133,20 +133,24 @@ function buildDescriptionPrompt(input: DescriptionInput): string {
const contentPreview = input.notes?.slice(0, 800) || '';
if (contentPreview) lines.push(`Notes: ${contentPreview}${input.notes && input.notes.length > 800 ? '...' : ''}`);
return `Your job is to answer: "what is this?" in one short line. Max 280 characters.
return `Write a description for this knowledge node. Max 280 characters.
GOAL: Include "who created it" when possible.
Say WHAT this literally is and WHY it matters. Be concrete and specific — like you're telling a friend what this thing is in one breath.
RULES (in priority order):
1) ONLY use a creator if you have a creator hint (Author/Channel) or the content explicitly says "by <X>" / "hosted by <X>".
- Do NOT treat prominent people in the title/transcript (e.g. a guest) as the creator.
- If a creator hint is provided, prefer it.
2) If you can identify a creator/author/channel/person/org from a creator hint, start with: "By <creator> — ..."
3) If it's likely user-authored, start with: "Your <thing> — ..." (don't invent a creator name).
4) If creator is unknown, do NOT guess; omit the byline.
RULES:
1) Name the format: "Podcast episode where…", "Blog post arguing…", "Your note on…", "Research paper showing…", "Idea that…"
2) Name people by role — channel/host is the creator, title figures are guests/subjects. Use the Creator hint if available.
3) State the actual claim, finding, or insight from the content — not a vague summary of the topic.
4) End with why it's interesting or important — one concrete phrase.
5) ABSOLUTELY FORBIDDEN — these words will be rejected: "discusses", "explores", "examines", "talks about", "is about", "delves into", "emphasizing the need for". State things directly instead.
Then, in the remainder, state what it is (video/paper/article/note/idea/etc) + what it's about (high-signal).
If unsure, say so briefly.
GOOD: "Karpathy blog post — AI agents make software fluid, ripping functionality from repos instead of taking dependencies. Signals the end of monolithic libraries."
GOOD: "Dwarkesh Patel interview with Anthropic CEO Dario Amodei — argues we're nearing the end of exponential AI scaling. Key signal for what comes next."
GOOD: "Your note — morning optimism consistently reverses to evening pessimism. Not energy — the belief itself flips. Pattern worth tracking."
BAD: "By Dario Amodei — discusses reaching the limits of exponential growth in AI, emphasizing the need for a critical perspective on future advancements."
BAD: "This article explores ideas about how software is changing."
Return ONLY the description text. Nothing else.
${lines.join('\n')}`;
}
+42
View File
@@ -7,6 +7,46 @@ export class NodeService {
return this.getNodesSQLite(filters);
}
async countNodes(filters: NodeFilters = {}): Promise<number> {
const { dimensions, search, dimensionsMatch = 'any',
createdAfter, createdBefore, eventAfter, eventBefore } = filters;
const sqlite = getSQLiteClient();
let query = `SELECT COUNT(*) as total FROM nodes n WHERE 1=1`;
const params: any[] = [];
if (dimensions && dimensions.length > 0) {
if (dimensionsMatch === 'all' && dimensions.length > 1) {
query += ` AND (
SELECT COUNT(DISTINCT nd.dimension) FROM node_dimensions nd
WHERE nd.node_id = n.id
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
) = ?`;
params.push(...dimensions, dimensions.length);
} else {
query += ` AND EXISTS (
SELECT 1 FROM node_dimensions nd
WHERE nd.node_id = n.id
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
)`;
params.push(...dimensions);
}
}
if (search) {
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.notes LIKE ? COLLATE NOCASE)`;
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
}
if (createdAfter) { query += ` AND n.created_at >= ?`; params.push(createdAfter); }
if (createdBefore) { query += ` AND n.created_at < ?`; params.push(createdBefore); }
if (eventAfter) { query += ` AND n.event_date >= ?`; params.push(eventAfter); }
if (eventBefore) { query += ` AND n.event_date < ?`; params.push(eventBefore); }
const result = sqlite.query<{ total: number }>(query, params);
return result.rows[0]?.total ?? 0;
}
// PostgreSQL path removed in SQLite-only consolidation
private async getNodesSQLite(filters: NodeFilters = {}): Promise<Node[]> {
@@ -94,6 +134,8 @@ export class NodeService {
query += ' ORDER BY edge_count DESC, n.updated_at DESC';
} else if (sortBy === 'created') {
query += ' ORDER BY n.created_at DESC';
} else if (sortBy === 'event_date') {
query += ' ORDER BY n.event_date IS NULL, n.event_date DESC, n.updated_at DESC';
} else {
query += ' ORDER BY n.updated_at DESC';
}
+2
View File
@@ -16,6 +16,8 @@ export interface DatabaseEvent {
| 'AGENT_DELEGATION_CREATED'
| 'AGENT_DELEGATION_UPDATED'
| 'GUIDE_UPDATED'
| 'QUICK_ADD_COMPLETED'
| 'QUICK_ADD_FAILED'
| 'CONNECTION_ESTABLISHED';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any;