Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,327 changes: 176 additions & 1,151 deletions apps/x/apps/renderer/src/App.tsx

Large diffs are not rendered by default.

69 changes: 69 additions & 0 deletions apps/x/apps/renderer/src/lib/copilot-prompts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Prompt strings the renderer sends to the copilot on behalf of a UI affordance
* (suggested-topic card, live-note setup, background-task setup/edit).
*
* Extracted verbatim from App.tsx — pure string building, no React.
*/

export const getSuggestedTopicTargetFolder = (category?: string) => {
const normalized = category?.trim().toLowerCase()
switch (normalized) {
case 'people':
case 'person':
return 'People'
case 'organizations':
case 'organization':
return 'Organizations'
case 'projects':
case 'project':
return 'Projects'
case 'meetings':
case 'meeting':
return 'Meetings'
case 'topics':
case 'topic':
default:
return 'Topics'
}
}

export const buildSuggestedTopicExplorePrompt = ({
title,
description,
category,
}: {
title: string
description: string
category?: string
}) => {
const folder = getSuggestedTopicTargetFolder(category)
const categoryLabel = category?.trim() || 'Topics'
return [
'I am exploring a suggested topic card from the Suggested Topics panel.',
'This card may represent a person, organization, topic, or project.',
'',
'Card context:',
`- Title: ${title}`,
`- Category: ${categoryLabel}`,
`- Description: ${description}`,
`- Target folder if we set this up: knowledge/${folder}/`,
'',
`Please start by telling me that you can set up a live note for "${title}" under knowledge/${folder}/.`,
'Then briefly explain what that live note would track and ask me if you should set it up.',
'Do not create or modify anything yet.',
'Treat a clear confirmation from me as explicit approval to proceed.',
`If I confirm later, load the \`live-note\` skill first, check whether a matching note already exists under knowledge/${folder}/, and extend its existing live objective instead of creating a duplicate.`,
`If no matching note exists, create a new note under knowledge/${folder}/ with an appropriate filename.`,
'Make the new note live (add a `live:` block to its frontmatter) rather than only writing static content, and keep any surrounding note scaffolding short and useful.',
'Do not ask me to choose a note path unless there is a real ambiguity you cannot resolve from the card.',
].join('\n')
}

export const buildLiveNoteSetupPrompt = () =>
'I want to set up a Live note / task.'

export const buildBgTaskSetupPrompt = (description: string) =>
`Create a background task for me. Here's what I want it to do:\n\n${description}`

export const buildBgTaskEditPrompt = (slug: string) =>
`Let's tweak the background task \`${slug}\`. Please load the \`background-task\` skill first, read the task's current \`bg-tasks/${slug}/task.yaml\`, then ask me what I want to change.`
43 changes: 43 additions & 0 deletions apps/x/apps/renderer/src/lib/frontmatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,49 @@
* in knowledge notes and email files.
*/

export type LinkedGoogleDocMeta = {
id: string
title: string
url?: string
syncedAt?: string
}

/**
* Pull the `google_doc:` block out of a raw frontmatter string.
*
* Returns null unless both `id` and `title` are present — a partial block means
* the note is not usably linked. Moved verbatim from App.tsx.
*/
export const parseLinkedGoogleDocFrontmatter = (raw: string | null | undefined): LinkedGoogleDocMeta | null => {
if (!raw?.includes('google_doc:')) return null
const doc: Partial<LinkedGoogleDocMeta> = {}
let inGoogleDoc = false
for (const line of raw.split('\n')) {
if (line.trim() === '---') {
inGoogleDoc = false
continue
}
const topLevel = line.match(/^([A-Za-z_][\w-]*):\s*.*$/)
if (topLevel) {
inGoogleDoc = topLevel[1] === 'google_doc'
continue
}
if (!inGoogleDoc) continue
const nested = line.match(/^\s+([A-Za-z_][\w-]*):\s*(.*)$/)
if (!nested) continue
const key = nested[1] as keyof LinkedGoogleDocMeta
if (!['id', 'title', 'url', 'syncedAt'].includes(key)) continue
let value = nested[2].trim()
try {
value = JSON.parse(value)
} catch {
value = value.replace(/^['"]|['"]$/g, '')
}
doc[key] = value
}
return doc.id && doc.title ? doc as LinkedGoogleDocMeta : null
}

/** Split content into raw frontmatter block and body text. */
export function splitFrontmatter(content: string): { raw: string | null; body: string } {
if (!content.startsWith('---')) {
Expand Down
184 changes: 184 additions & 0 deletions apps/x/apps/renderer/src/lib/knowledge-tree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/**
* Knowledge sidebar tree: building, sorting, and flattening the file tree that
* backs the knowledge/ pane.
*
* Extracted verbatim from App.tsx — pure functions, no React, no IPC.
*/

import z from 'zod'
import { workspace } from '@x/shared'

type DirEntry = z.infer<typeof workspace.DirEntry>

export interface TreeNode extends DirEntry {
children?: TreeNode[]
loaded?: boolean
}

// Sidebar folder ordering — listed folders appear in this order, unlisted ones follow alphabetically
export const FOLDER_ORDER = ['People', 'Organizations', 'Projects', 'Topics', 'Meetings', 'Agent Notes', 'Notes']

/**
* Per-folder base view config: which columns to show and default sort.
* Folders not listed here fall back to DEFAULT_BASE_CONFIG.
*/
export const FOLDER_BASE_CONFIGS: Record<string, { visibleColumns: string[]; sort: { field: string; dir: 'asc' | 'desc' } }> = {
'Agent Notes': {
visibleColumns: ['name', 'folder', 'mtimeMs'],
sort: { field: 'mtimeMs', dir: 'desc' },
},
People: {
visibleColumns: ['name', 'relationship', 'organization', 'mtimeMs'],
sort: { field: 'name', dir: 'asc' },
},
Organizations: {
visibleColumns: ['name', 'relationship', 'mtimeMs'],
sort: { field: 'name', dir: 'asc' },
},
Projects: {
visibleColumns: ['name', 'status', 'topic', 'mtimeMs'],
sort: { field: 'name', dir: 'asc' },
},
Topics: {
visibleColumns: ['name', 'mtimeMs'],
sort: { field: 'name', dir: 'asc' },
},
Meetings: {
visibleColumns: ['name', 'topic', 'mtimeMs'],
sort: { field: 'mtimeMs', dir: 'desc' },
},
}

// Sort nodes (dirs first, ordered folders by FOLDER_ORDER, then alphabetically)
export function sortNodes(nodes: TreeNode[]): TreeNode[] {
return nodes.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
const aOrder = FOLDER_ORDER.indexOf(a.name)
const bOrder = FOLDER_ORDER.indexOf(b.name)
if (aOrder !== -1 && bOrder !== -1) return aOrder - bOrder
if (aOrder !== -1) return -1
if (bOrder !== -1) return 1
return a.name.localeCompare(b.name)
}).map(node => {
if (node.children) {
node.children = sortNodes(node.children)
}
return node
})
}

/**
* Organize Meetings/ source folders into date-grouped subfolders.
*
* - rowboat: rowboat/2026-03-20/meeting-xxx.md → keeps date folders as-is
* - granola: granola/2026/03/18/Title.md → collapses into "2026-03-18" folders
* - Files directly under a source folder (no date subfolder) are grouped
* by the date prefix in their filename (e.g. meeting-2026-03-17T...).
*/
export function flattenMeetingsTree(nodes: TreeNode[]): TreeNode[] {
return nodes.flatMap(node => {
if (node.kind !== 'dir' || node.name !== 'Meetings') return [node]

const flattenedSourceChildren = (node.children ?? []).flatMap(sourceNode => {
if (sourceNode.kind !== 'dir') return [sourceNode]

// Collect all files with their date group label
const dateGroups = new Map<string, TreeNode[]>()

function collectFiles(n: TreeNode, dateParts: string[]) {
for (const child of n.children ?? []) {
if (child.kind === 'file') {
const dateStr = dateParts.join('-')
// If file is at root of source folder, try to extract date from filename
const groupKey = dateStr || extractDateFromFilename(child.name) || 'other'
const group = dateGroups.get(groupKey) ?? []
group.push(child)
dateGroups.set(groupKey, group)
} else if (child.kind === 'dir') {
collectFiles(child, [...dateParts, child.name])
}
}
}
collectFiles(sourceNode, [])

// Pass through user-created folders that have no meeting-style date files
if (dateGroups.size === 0) return [sourceNode]

// Build date folder nodes, sorted reverse chronologically
const dateFolderNodes: TreeNode[] = [...dateGroups.entries()]
.sort(([a], [b]) => b.localeCompare(a))
.map(([dateKey, files]) => {
// Sort files within each date group reverse chronologically
files.sort((a, b) => b.name.localeCompare(a.name))
return {
name: dateKey,
path: `${sourceNode.path}/${dateKey}`,
kind: 'dir' as const,
children: files,
loaded: true,
}
})

return [{ ...sourceNode, children: dateFolderNodes }]
})

// Hide Meetings folder entirely if no source folders have files
if (flattenedSourceChildren.length === 0) return []

return [{ ...node, children: flattenedSourceChildren }]
})
}

/** Extract YYYY-MM-DD from filenames like "meeting-2026-03-17T05-01-47.md" */
export function extractDateFromFilename(name: string): string | null {
const match = name.match(/(\d{4}-\d{2}-\d{2})/)
return match ? match[1] : null
}

// Build tree structure from flat entries
export function buildTree(entries: DirEntry[]): TreeNode[] {
const treeMap = new Map<string, TreeNode>()
const roots: TreeNode[] = []

// Create nodes
entries.forEach(entry => {
const node: TreeNode = { ...entry, children: [], loaded: false }
treeMap.set(entry.path, node)
})

// Build hierarchy
entries.forEach(entry => {
const node = treeMap.get(entry.path)!
const parts = entry.path.split('/')
if (parts.length === 1) {
roots.push(node)
} else {
const parentPath = parts.slice(0, -1).join('/')
const parent = treeMap.get(parentPath)
if (parent) {
if (!parent.children) parent.children = []
parent.children.push(node)
} else {
roots.push(node)
}
}
})

return sortNodes(roots)
}

export const collectDirPaths = (nodes: TreeNode[]): string[] =>
nodes.flatMap(n => n.kind === 'dir' ? [n.path, ...(n.children ? collectDirPaths(n.children) : [])] : [])

export const collectFilePaths = (nodes: TreeNode[]): string[] =>
nodes.flatMap(n => n.kind === 'file' ? [n.path] : (n.children ? collectFilePaths(n.children) : []))

export const getAncestorDirectoryPaths = (path: string): string[] => {
const parts = path.split('/').filter(Boolean)
if (parts.length <= 2) return []
const ancestors: string[] = []
for (let i = 1; i < parts.length - 1; i++) {
ancestors.push(parts.slice(0, i + 1).join('/'))
}
return ancestors
}
58 changes: 58 additions & 0 deletions apps/x/apps/renderer/src/lib/view-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import {
APPS_TAB_PATH,
BG_TASKS_TAB_PATH,
CHAT_HISTORY_TAB_PATH,
CODE_TAB_PATH,
EMAIL_TAB_PATH,
GRAPH_TAB_PATH,
HOME_TAB_PATH,
KNOWLEDGE_VIEW_TAB_PATH,
LIVE_NOTES_TAB_PATH,
MEETINGS_TAB_PATH,
SUGGESTED_TOPICS_TAB_PATH,
WORKSPACE_TAB_PATH,
type ViewState,
viewForTabPath,
} from './view-state'

// Every sentinel tab path and the view it must produce. A sentinel missing its
// branch in viewForTabPath falls through to `{ type: 'file' }`, which renders a
// blank middle pane instead of erroring — the failure mode the ViewState
// collapse exists to make impossible.
const SENTINELS: Array<[string, ViewState['type']]> = [
[GRAPH_TAB_PATH, 'graph'],
[SUGGESTED_TOPICS_TAB_PATH, 'suggested-topics'],
[MEETINGS_TAB_PATH, 'meetings'],
[LIVE_NOTES_TAB_PATH, 'live-notes'],
[BG_TASKS_TAB_PATH, 'bg-tasks'],
[APPS_TAB_PATH, 'apps'],
[EMAIL_TAB_PATH, 'email'],
[WORKSPACE_TAB_PATH, 'workspace'],
[KNOWLEDGE_VIEW_TAB_PATH, 'knowledge-view'],
[CHAT_HISTORY_TAB_PATH, 'chat-history'],
[HOME_TAB_PATH, 'home'],
[CODE_TAB_PATH, 'code'],
]

describe('viewForTabPath', () => {
it.each(SENTINELS)('maps %s to the %s view', (path, type) => {
expect(viewForTabPath(path).type).toBe(type)
})

it('gives every sentinel a distinct view', () => {
const types = SENTINELS.map(([path]) => viewForTabPath(path).type)
expect(new Set(types).size).toBe(SENTINELS.length)
})

it('treats a real note path as a file view', () => {
expect(viewForTabPath('knowledge/Topics/Ada.md')).toEqual({
type: 'file',
path: 'knowledge/Topics/Ada.md',
})
})

it('treats a .base path as a file view (bases render from selectedPath)', () => {
expect(viewForTabPath('knowledge/People.base').type).toBe('file')
})
})
Loading