Compare commits

...

12 commits

Author SHA1 Message Date
Arjun
b2e8cd666b add icons to chat history 2026-05-19 20:57:11 +05:30
Arjun
9e36ff2fa1 email in main sidebar 2026-05-19 20:14:09 +05:30
Arjun
1a3e5a4e2e track meeting recoring and show active recording in the sidebar row 2026-05-19 19:46:29 +05:30
Arjun
5eea5907e7 meetings in sidebar 2026-05-19 19:31:37 +05:30
Arjun
2da914b6e7 move tasks to main side bar 2026-05-19 19:19:00 +05:30
Arjun
f8100d353d added chat history page 2026-05-19 18:53:24 +05:30
Arjun
e678dd71a0 workspace side bar 2026-05-19 18:34:34 +05:30
Arjun
43537c5e20 view more in knowledge 2026-05-19 18:24:48 +05:30
Arjun
1b2ed1bc2e default to workspace in set directory 2026-05-19 10:31:37 +05:30
Arjun
c36f17130f add workspaces 2026-05-19 10:23:12 +05:30
Arjun
48eb9214c8 shortcut for new chat 2026-05-18 23:22:54 +05:30
Arjun
215e0aedd6 make new chat an icon on the top 2026-05-18 23:18:47 +05:30
9 changed files with 2063 additions and 890 deletions

File diff suppressed because it is too large Load diff

View file

@ -1419,6 +1419,18 @@ export interface BgTasksViewProps {
* "Edit with Copilot" button in the detail-view sidebar footer.
*/
onEditWithCopilot?: (slug: string) => void
/**
* If provided, the view opens with this task already selected. Updates to
* this prop sync into internal state so the sidebar can swap which task is
* focused without remounting the view.
*/
initialSlug?: string | null
/**
* Bump this counter to force a re-focus on `initialSlug` even when the
* slug value itself didn't change (e.g. user clicks the same task in the
* sidebar twice after navigating away inside the view).
*/
slugVersion?: number
}
function formatLastRanLabel(iso: string | null | undefined): string {
@ -1426,9 +1438,12 @@ function formatLastRanLabel(iso: string | null | undefined): string {
return formatRelativeTime(iso) || 'Never'
}
export function BgTasksView({ onCreateWithCopilot, onEditWithCopilot }: BgTasksViewProps = {}) {
export function BgTasksView({ onCreateWithCopilot, onEditWithCopilot, initialSlug, slugVersion }: BgTasksViewProps = {}) {
const [items, setItems] = useState<BackgroundTaskSummary[]>([])
const [selectedSlug, setSelectedSlug] = useState<string | null>(null)
const [selectedSlug, setSelectedSlug] = useState<string | null>(initialSlug ?? null)
useEffect(() => {
setSelectedSlug(initialSlug ?? null)
}, [initialSlug, slugVersion])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showNewDialog, setShowNewDialog] = useState(false)

View file

@ -0,0 +1,177 @@
import { useCallback, useMemo, useState } from 'react'
import { ExternalLink, MessageSquare, SearchIcon, SquarePen, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { formatRelativeTime } from '@/lib/relative-time'
type Run = {
id: string
title?: string
createdAt: string
agentId: string
}
type ChatHistoryViewProps = {
runs: Run[]
currentRunId?: string | null
processingRunIds?: Set<string>
onSelectRun: (runId: string) => void
onOpenInNewTab?: (runId: string) => void
onDeleteRun: (runId: string) => Promise<void> | void
onNewChat?: () => void
onOpenSearch?: () => void
}
export function ChatHistoryView({
runs,
currentRunId,
processingRunIds,
onSelectRun,
onOpenInNewTab,
onDeleteRun,
onNewChat,
onOpenSearch,
}: ChatHistoryViewProps) {
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
const sortedRuns = useMemo(() => {
return [...runs].sort((a, b) => {
const at = new Date(a.createdAt).getTime()
const bt = new Date(b.createdAt).getTime()
return (Number.isNaN(bt) ? 0 : bt) - (Number.isNaN(at) ? 0 : at)
})
}, [runs])
const handleConfirmDelete = useCallback(async () => {
if (!pendingDeleteId) return
const id = pendingDeleteId
setPendingDeleteId(null)
await onDeleteRun(id)
}, [pendingDeleteId, onDeleteRun])
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-border px-8 py-6">
<h1 className="text-2xl font-bold tracking-tight">Chat history</h1>
<div className="flex items-center gap-2">
{onOpenSearch && (
<button
type="button"
onClick={onOpenSearch}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<SearchIcon className="size-4" />
<span>Search</span>
</button>
)}
{onNewChat && (
<Button size="sm" onClick={onNewChat}>
<SquarePen className="size-4" />
New chat
</Button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="min-w-[480px]">
<div className="sticky top-0 z-10 flex items-center border-b border-border bg-background px-6 py-2 text-xs font-medium text-muted-foreground">
<div className="flex-1">Title</div>
<div className="w-32 shrink-0">Created</div>
</div>
{sortedRuns.length === 0 ? (
<div className="px-6 py-8 text-sm text-muted-foreground">No chats yet.</div>
) : (
sortedRuns.map((run) => {
const isActive = currentRunId === run.id
const isProcessing = processingRunIds?.has(run.id)
return (
<ContextMenu key={run.id}>
<ContextMenuTrigger asChild>
<button
type="button"
onClick={(e) => {
if (e.metaKey && onOpenInNewTab) {
onOpenInNewTab(run.id)
} else {
onSelectRun(run.id)
}
}}
className={[
'flex w-full items-center border-b border-border/60 px-6 py-1.5 text-left text-sm transition-colors hover:bg-accent',
isActive ? 'bg-accent/60' : '',
].join(' ')}
>
<div className="flex flex-1 items-center gap-2 min-w-0">
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">{run.title || '(Untitled chat)'}</span>
</div>
<div className="w-32 shrink-0 text-xs text-muted-foreground tabular-nums">
{formatRelativeTime(run.createdAt)}
</div>
</button>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{onOpenInNewTab && (
<>
<ContextMenuItem onClick={() => onOpenInNewTab(run.id)}>
<ExternalLink className="mr-2 size-4" />
Open in new tab
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{!isProcessing && (
<ContextMenuItem
variant="destructive"
onClick={() => setPendingDeleteId(run.id)}
>
<Trash2 className="mr-2 size-4" />
Delete
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
)
})
)}
</div>
</div>
<Dialog open={!!pendingDeleteId} onOpenChange={(open) => { if (!open) setPendingDeleteId(null) }}>
<DialogContent showCloseButton={false} className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete chat</DialogTitle>
<DialogDescription>
Are you sure you want to delete this chat?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setPendingDeleteId(null)}>
Cancel
</Button>
<Button variant="destructive" onClick={() => void handleConfirmDelete()}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -274,9 +274,21 @@ function ChatInputInner({
const handleSetWorkDir = useCallback(async () => {
try {
let defaultPath: string | undefined = workDir ?? undefined
try {
const { root } = await window.ipc.invoke('workspace:getRoot', null)
const workspaceRel = 'knowledge/Workspace'
const exists = await window.ipc.invoke('workspace:exists', { path: workspaceRel })
if (!exists.exists) {
await window.ipc.invoke('workspace:mkdir', { path: workspaceRel, recursive: true })
}
defaultPath = `${root.replace(/\/$/, '')}/${workspaceRel}`
} catch (err) {
console.error('Failed to resolve Workspace path; falling back to current workDir', err)
}
const { path: chosen } = await window.ipc.invoke('dialog:openDirectory', {
title: 'Choose work directory',
defaultPath: workDir ?? undefined,
defaultPath,
})
if (!chosen) return
await window.ipc.invoke('workspace:writeFile', {

View file

@ -817,12 +817,28 @@ function clearLoadingFlag(state: SectionState | null): SectionState {
return { ...state, loadingPage: false }
}
export function EmailView() {
export type EmailViewProps = {
/** If provided, the view opens with this thread already expanded. */
initialThreadId?: string | null
/** Bump to re-focus on the same threadId after navigating away inside the view. */
threadIdVersion?: number
}
export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = {}) {
const [important, setImportant] = useState<SectionState>(() => clearLoadingFlag(persistedImportant))
const [other, setOther] = useState<SectionState>(() => clearLoadingFlag(persistedOther))
const hadPersistedDataOnMount = useRef(persistedImportant !== null)
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null)
const [openedThreadIds, setOpenedThreadIds] = useState<string[]>([])
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(initialThreadId ?? null)
const [openedThreadIds, setOpenedThreadIds] = useState<string[]>(initialThreadId ? [initialThreadId] : [])
useEffect(() => {
setSelectedThreadId(initialThreadId ?? null)
if (initialThreadId) {
setOpenedThreadIds((prev) => {
const without = prev.filter((id) => id !== initialThreadId)
return [...without, initialThreadId].slice(-MAX_KEPT_OPEN)
})
}
}, [initialThreadId, threadIdVersion])
const [refreshing, setRefreshing] = useState(!hadPersistedDataOnMount.current)
const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('')

View file

@ -1,33 +1,11 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { AlertCircleIcon, ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react'
const MAX_SIZE_BYTES = 5 * 1024 * 1024
const CACHE_MAX_ENTRIES = 20
type CacheEntry = { html: string; mtimeMs: number; size: number }
const htmlCache = new Map<string, CacheEntry>()
function getCached(path: string, mtimeMs: number, size: number): string | null {
const entry = htmlCache.get(path)
if (!entry || entry.mtimeMs !== mtimeMs || entry.size !== size) return null
// Refresh LRU position
htmlCache.delete(path)
htmlCache.set(path, entry)
return entry.html
}
function setCached(path: string, html: string, mtimeMs: number, size: number) {
htmlCache.set(path, { html, mtimeMs, size })
while (htmlCache.size > CACHE_MAX_ENTRIES) {
const oldest = htmlCache.keys().next().value
if (oldest === undefined) break
htmlCache.delete(oldest)
}
}
type ViewerState =
| { kind: 'loading' }
| { kind: 'loaded'; html: string }
| { kind: 'loaded' }
| { kind: 'empty' }
| { kind: 'tooLarge'; sizeMB: number }
| { kind: 'error'; message: string }
@ -36,9 +14,15 @@ interface HtmlFileViewerProps {
path: string
}
function toAppWorkspaceUrl(path: string): string {
const segments = path.split('/').filter(Boolean).map((seg) => encodeURIComponent(seg))
return `app://workspace/${segments.join('/')}`
}
export function HtmlFileViewer({ path }: HtmlFileViewerProps) {
const [state, setState] = useState<ViewerState>({ kind: 'loading' })
const [iframeLoaded, setIframeLoaded] = useState(false)
const iframeSrc = useMemo(() => toAppWorkspaceUrl(path), [path])
useEffect(() => {
let cancelled = false
@ -57,19 +41,11 @@ export function HtmlFileViewer({ path }: HtmlFileViewerProps) {
setState({ kind: 'tooLarge', sizeMB: stat.size / (1024 * 1024) })
return
}
const cachedHtml = getCached(path, stat.mtimeMs, stat.size)
if (cachedHtml !== null) {
setState(cachedHtml.trim() === '' ? { kind: 'empty' } : { kind: 'loaded', html: cachedHtml })
return
}
const result = await window.ipc.invoke('workspace:readFile', { path })
if (cancelled) return
setCached(path, result.data, stat.mtimeMs, stat.size)
if (!result.data || result.data.trim() === '') {
if (stat.size === 0) {
setState({ kind: 'empty' })
return
}
setState({ kind: 'loaded', html: result.data })
setState({ kind: 'loaded' })
} catch (err) {
if (cancelled) return
const message = err instanceof Error ? err.message : String(err)
@ -124,20 +100,16 @@ export function HtmlFileViewer({ path }: HtmlFileViewerProps) {
)
}
// We use `srcDoc` here (not `src=app://workspace/<path>`) so the iframe
// gets a null origin with no base URL. Trade-off: relative assets inside
// the file — `<link href="./style.css">`, `<img src="./pic.png">`,
// `<script src="./foo.js">` — will silently 404. Self-contained HTML
// works fine; HTML that ships next to sibling assets will look broken.
// TODO: switch to `src=app://workspace/<path>` if we want relative-asset
// support; that path also resolves through the existing path-traversal
// guard in resolveWorkspacePath.
// Serve via the `app://workspace/<rel-path>` protocol so the iframe has a
// proper base URL — relative `<link>`, `<img>`, `<script>` references next
// to the file resolve correctly (the path-traversal guard in
// resolveWorkspacePath already gates the protocol handler).
return (
<div className="relative h-full w-full">
{state.kind === 'loaded' && (
<iframe
key={path}
srcDoc={state.html}
src={iframeSrc}
sandbox="allow-scripts"
className="h-full w-full border-0 bg-white"
title="HTML preview"

View file

@ -0,0 +1,405 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ChevronRight,
Copy,
ExternalLink,
File as FileIcon,
FilePlus,
Folder as FolderIcon,
FolderOpen,
FolderPlus,
Network,
Pencil,
SearchIcon,
Table2,
Trash2,
} from 'lucide-react'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import { Input } from '@/components/ui/input'
import { VoiceNoteButton } from '@/components/sidebar-content'
import { formatRelativeTime } from '@/lib/relative-time'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
interface TreeNode {
path: string
name: string
kind: 'file' | 'dir'
children?: TreeNode[]
stat?: { size: number; mtimeMs: number }
}
export type KnowledgeViewActions = {
createNote: (parentPath?: string) => void
createFolder: (parentPath?: string) => Promise<string>
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
remove: (path: string) => Promise<void>
copyPath: (path: string) => void
revealInFileManager: (path: string, isDir: boolean) => void
onOpenInNewTab?: (path: string) => void
}
type KnowledgeViewProps = {
tree: TreeNode[]
actions: KnowledgeViewActions
onOpenNote: (path: string) => void
onOpenGraph: () => void
onOpenSearch: () => void
onOpenBases: () => void
onVoiceNoteCreated?: (path: string) => void
}
type FlatRow = {
node: TreeNode
depth: number
}
function sortNodes(nodes: TreeNode[]): TreeNode[] {
return [...nodes].sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
return a.name.localeCompare(b.name)
})
}
function flatten(
nodes: TreeNode[],
expanded: Set<string>,
depth: number,
out: FlatRow[],
): void {
for (const node of sortNodes(nodes)) {
out.push({ node, depth })
if (node.kind === 'dir' && expanded.has(node.path) && node.children?.length) {
flatten(node.children, expanded, depth + 1, out)
}
}
}
function formatModified(mtimeMs?: number): string {
if (!mtimeMs) return ''
return formatRelativeTime(new Date(mtimeMs).toISOString())
}
function getFileManagerName(): string {
if (typeof navigator === 'undefined') return 'File Manager'
const platform = navigator.platform.toLowerCase()
if (platform.includes('mac')) return 'Finder'
if (platform.includes('win')) return 'Explorer'
return 'File Manager'
}
function displayName(node: TreeNode): string {
if (node.kind === 'file' && node.name.toLowerCase().endsWith('.md')) {
return node.name.slice(0, -3)
}
return node.name
}
const INDENT_PX = 16
const ROW_PADDING_PX = 12
export function KnowledgeView({
tree,
actions,
onOpenNote,
onOpenGraph,
onOpenSearch,
onOpenBases,
onVoiceNoteCreated,
}: KnowledgeViewProps) {
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [renameTarget, setRenameTarget] = useState<string | null>(null)
const rows = useMemo<FlatRow[]>(() => {
const out: FlatRow[] = []
flatten(tree, expanded, 0, out)
return out
}, [tree, expanded])
const handleRowClick = useCallback(
(node: TreeNode) => {
if (node.kind === 'dir') {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(node.path)) next.delete(node.path)
else next.add(node.path)
return next
})
} else {
onOpenNote(node.path)
}
},
[onOpenNote],
)
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-border px-8 py-6">
<h1 className="text-2xl font-bold tracking-tight">Knowledge</h1>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => actions.createNote()}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<FilePlus className="size-4" />
<span>New note</span>
</button>
<button
type="button"
onClick={() => void actions.createFolder()}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<FolderPlus className="size-4" />
<span>New folder</span>
</button>
<VoiceNoteButton onNoteCreated={onVoiceNoteCreated} />
<button
type="button"
onClick={onOpenSearch}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<SearchIcon className="size-4" />
<span>Search</span>
</button>
<button
type="button"
onClick={onOpenBases}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<Table2 className="size-4" />
<span>Bases</span>
</button>
<button
type="button"
onClick={onOpenGraph}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<Network className="size-4" />
<span>Graph view</span>
</button>
<button
type="button"
onClick={() => actions.revealInFileManager('knowledge', true)}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<FolderOpen className="size-4" />
<span>Open in {getFileManagerName()}</span>
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="min-w-[480px]">
<div className="sticky top-0 z-10 flex items-center border-b border-border bg-background px-6 py-2 text-xs font-medium text-muted-foreground">
<div className="flex-1">Page name</div>
<div className="w-32 shrink-0">Modified</div>
</div>
{rows.length === 0 ? (
<div className="px-6 py-8 text-sm text-muted-foreground">No pages yet.</div>
) : (
rows.map(({ node, depth }) => (
<KnowledgeRow
key={node.path}
node={node}
depth={depth}
isExpanded={expanded.has(node.path)}
actions={actions}
renameActive={renameTarget === node.path}
onRequestRename={(p) => setRenameTarget(p)}
onClearRename={() => setRenameTarget(null)}
onClick={handleRowClick}
/>
))
)}
</div>
</div>
</div>
)
}
function KnowledgeRow({
node,
depth,
isExpanded,
actions,
renameActive,
onRequestRename,
onClearRename,
onClick,
}: {
node: TreeNode
depth: number
isExpanded: boolean
actions: KnowledgeViewActions
renameActive: boolean
onRequestRename: (path: string) => void
onClearRename: () => void
onClick: (node: TreeNode) => void
}) {
const isDir = node.kind === 'dir'
const Icon = isDir ? FolderIcon : FileIcon
const paddingLeft = ROW_PADDING_PX + depth * INDENT_PX
const baseName = displayName(node)
const [newName, setNewName] = useState(baseName)
const inputRef = useRef<HTMLInputElement | null>(null)
const isSubmittingRef = useRef(false)
useEffect(() => {
if (renameActive) {
setNewName(baseName)
isSubmittingRef.current = false
// focus on next tick after mount
requestAnimationFrame(() => {
inputRef.current?.focus()
inputRef.current?.select()
})
}
}, [renameActive, baseName])
const handleRenameSubmit = useCallback(async () => {
if (isSubmittingRef.current) return
isSubmittingRef.current = true
const trimmed = newName.trim()
if (trimmed && trimmed !== baseName) {
try {
await actions.rename(node.path, trimmed, isDir)
toast('Renamed successfully', 'success')
} catch {
toast('Failed to rename', 'error')
}
}
onClearRename()
setTimeout(() => {
isSubmittingRef.current = false
}, 100)
}, [actions, baseName, isDir, newName, node.path, onClearRename])
const cancelRename = useCallback(() => {
isSubmittingRef.current = true
setNewName(baseName)
onClearRename()
setTimeout(() => {
isSubmittingRef.current = false
}, 100)
}, [baseName, onClearRename])
const handleDelete = useCallback(async () => {
try {
await actions.remove(node.path)
toast('Moved to trash', 'success')
} catch {
toast('Failed to delete', 'error')
}
}, [actions, node.path])
const handleCopyPath = useCallback(() => {
actions.copyPath(node.path)
toast('Path copied', 'success')
}, [actions, node.path])
const row = (
<button
type="button"
onClick={() => onClick(node)}
className="group flex w-full items-center border-b border-border/60 px-6 py-1.5 text-left text-sm transition-colors hover:bg-accent"
>
<div className="flex flex-1 items-center gap-1.5 min-w-0" style={{ paddingLeft }}>
<span className="inline-flex w-4 shrink-0 items-center justify-center text-muted-foreground">
{isDir ? (
<ChevronRight
className={cn(
'size-3.5 transition-transform',
isExpanded && 'rotate-90',
)}
/>
) : null}
</span>
<Icon className="size-4 shrink-0 text-muted-foreground" />
{renameActive ? (
<Input
ref={inputRef}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') {
e.preventDefault()
void handleRenameSubmit()
} else if (e.key === 'Escape') {
e.preventDefault()
cancelRename()
}
}}
onBlur={() => {
if (!isSubmittingRef.current) void handleRenameSubmit()
}}
className="h-6 text-sm flex-1"
/>
) : (
<span className="min-w-0 truncate">{baseName}</span>
)}
</div>
<div className="w-32 shrink-0 text-xs text-muted-foreground tabular-nums">
{formatModified(node.stat?.mtimeMs)}
</div>
</button>
)
return (
<ContextMenu>
<ContextMenuTrigger asChild>{row}</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{isDir && (
<>
<ContextMenuItem onClick={() => actions.createNote(node.path)}>
<FilePlus className="mr-2 size-4" />
New Note
</ContextMenuItem>
<ContextMenuItem onClick={() => void actions.createFolder(node.path)}>
<FolderPlus className="mr-2 size-4" />
New Folder
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{!isDir && actions.onOpenInNewTab && (
<>
<ContextMenuItem onClick={() => actions.onOpenInNewTab!(node.path)}>
<ExternalLink className="mr-2 size-4" />
Open in new tab
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
<ContextMenuItem onClick={handleCopyPath}>
<Copy className="mr-2 size-4" />
Copy Path
</ContextMenuItem>
<ContextMenuItem onClick={() => actions.revealInFileManager(node.path, isDir)}>
<FolderOpen className="mr-2 size-4" />
Open in {getFileManagerName()}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onRequestRename(node.path)}>
<Pencil className="mr-2 size-4" />
Rename
</ContextMenuItem>
<ContextMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="mr-2 size-4" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,262 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ChevronRight, File as FileIcon, Folder as FolderIcon, Home, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
const WORKSPACE_ROOT = 'knowledge/Workspace'
interface TreeNode {
path: string
name: string
kind: 'file' | 'dir'
children?: TreeNode[]
}
type WorkspaceViewProps = {
tree: TreeNode[]
initialPath?: string | null
onOpenNote: (path: string) => void
onCreateWorkspace: (name: string) => Promise<void>
}
function findNode(nodes: TreeNode[] | undefined, path: string): TreeNode | null {
if (!nodes) return null
for (const node of nodes) {
if (node.path === path) return node
if (node.kind === 'dir' && path.startsWith(`${node.path}/`)) {
const found = findNode(node.children, path)
if (found) return found
}
}
return null
}
function countChildren(node: TreeNode | null): number {
if (!node || node.kind !== 'dir' || !node.children) return 0
return node.children.length
}
export function WorkspaceView({ tree, initialPath, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) {
const [currentPath, setCurrentPath] = useState<string>(initialPath || WORKSPACE_ROOT)
const [addOpen, setAddOpen] = useState(false)
const [newName, setNewName] = useState('')
const [creating, setCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (initialPath) setCurrentPath(initialPath)
}, [initialPath])
const isRoot = currentPath === WORKSPACE_ROOT
const currentNode = useMemo(() => findNode(tree, currentPath), [tree, currentPath])
const items = useMemo<TreeNode[]>(() => {
const children = currentNode?.children ?? []
const filtered = isRoot ? children.filter((c) => c.kind === 'dir') : children
return [...filtered].sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
return a.name.localeCompare(b.name)
})
}, [currentNode, isRoot])
const breadcrumbs = useMemo(() => {
if (isRoot) return [] as { path: string; name: string }[]
const rel = currentPath.slice(WORKSPACE_ROOT.length + 1)
const parts = rel.split('/').filter(Boolean)
let acc = WORKSPACE_ROOT
return parts.map((seg) => {
acc = `${acc}/${seg}`
return { path: acc, name: seg }
})
}, [currentPath, isRoot])
const handleItemClick = useCallback(
(item: TreeNode) => {
if (item.kind === 'dir') {
setCurrentPath(item.path)
} else {
onOpenNote(item.path)
}
},
[onOpenNote],
)
const resetAddDialog = useCallback(() => {
setNewName('')
setError(null)
setCreating(false)
}, [])
const handleCreate = useCallback(async () => {
const trimmed = newName.trim()
if (!trimmed) {
setError('Name is required')
return
}
if (trimmed.includes('/')) {
setError('Name cannot contain "/"')
return
}
setCreating(true)
setError(null)
try {
await onCreateWorkspace(trimmed)
setAddOpen(false)
resetAddDialog()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create workspace')
setCreating(false)
}
}, [newName, onCreateWorkspace, resetAddDialog])
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-6 py-4">
<div className="flex min-w-0 items-center gap-1 text-sm">
<button
type="button"
onClick={() => setCurrentPath(WORKSPACE_ROOT)}
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 transition-colors',
isRoot ? 'text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-accent',
)}
>
<Home className="size-4" />
<span className="font-medium">Workspace</span>
</button>
{breadcrumbs.map((crumb, idx) => {
const isLast = idx === breadcrumbs.length - 1
return (
<span key={crumb.path} className="flex items-center gap-1">
<ChevronRight className="size-4 text-muted-foreground/60" />
{isLast ? (
<span className="rounded-md px-2 py-1 font-medium text-foreground truncate">
{crumb.name}
</span>
) : (
<button
type="button"
onClick={() => setCurrentPath(crumb.path)}
className="rounded-md px-2 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground truncate"
>
{crumb.name}
</button>
)}
</span>
)
})}
</div>
{isRoot && (
<Button size="sm" onClick={() => setAddOpen(true)}>
<Plus className="size-4" />
Add workspace
</Button>
)}
</div>
<div className="flex-1 overflow-y-auto px-6 py-6">
{items.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center text-muted-foreground">
<FolderIcon className="size-10 opacity-50" />
<div className="text-sm">
{isRoot
? 'No workspaces yet. Create one to get started.'
: 'This folder is empty.'}
</div>
{isRoot && (
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}>
<Plus className="size-4" />
Add workspace
</Button>
)}
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-3">
{items.map((item) => {
const childCount = item.kind === 'dir' ? countChildren(item) : 0
const Icon = item.kind === 'dir' ? FolderIcon : FileIcon
return (
<button
key={item.path}
type="button"
onClick={() => handleItemClick(item)}
className="group flex flex-col items-start gap-2 rounded-lg border border-border bg-card p-4 text-left transition-colors hover:border-foreground/20 hover:bg-accent"
>
<Icon className="size-6 text-muted-foreground group-hover:text-foreground" />
<div className="min-w-0 w-full">
<div className="truncate text-sm font-medium">{item.name}</div>
{item.kind === 'dir' && (
<div className="text-xs text-muted-foreground">
{childCount} {childCount === 1 ? 'item' : 'items'}
</div>
)}
</div>
</button>
)
})}
</div>
)}
</div>
<Dialog
open={addOpen}
onOpenChange={(open) => {
setAddOpen(open)
if (!open) resetAddDialog()
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>New workspace</DialogTitle>
<DialogDescription>
Workspaces are top-level folders inside knowledge/Workspace.
</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
<label htmlFor="workspace-name" className="text-sm font-medium">Name</label>
<Input
id="workspace-name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="e.g. Alpha"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && !creating) {
e.preventDefault()
void handleCreate()
}
}}
/>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setAddOpen(false)
resetAddDialog()
}}
disabled={creating}
>
Cancel
</Button>
<Button onClick={() => void handleCreate()} disabled={creating || !newName.trim()}>
{creating ? 'Creating…' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}