Compare commits

...

10 Commits

Author SHA1 Message Date
Test
e697b4b5e5 feat: Claude Code self-dispatches next task autonomously after each completion 2026-03-30 17:02:12 +02:00
Test
6b0bb5173c feat: pre-populate commit dialog with heuristic message from git diff
Generate a commit message from modified files (e.g. "Update winter-2026"
or "Update 12 notes") and pre-fill the CommitDialog textarea so users can
commit immediately or edit the suggestion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:30:54 +02:00
Test
81f986a065 fix: remove left indent from title when no emoji icon present 2026-03-30 15:35:43 +02:00
Test
564ca50206 fix: move 'Add icon' button above title when no emoji (Notion-style)
When a note has no emoji icon, the NoteIcon area was occupying horizontal
space in the title row, pushing the title text to the right. Fix: render
NoteIcon in a separate div above the title row when no emoji is set, so
the title starts flush with the left margin. When an emoji is present,
the original inline-left layout is preserved.
2026-03-30 15:16:55 +02:00
Test
6d405a763d feat: move Changes and Pulse from sidebar to bottom status bar
Changes badge shows GitDiff icon with orange count badge, Pulse badge
sits next to it. Commit & Push is accessible via icon button beside
Changes. Sidebar is now cleaner with only nav filters and sections.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:01:57 +02:00
Test
7c9bc3d640 chore: update ui-design.pen 2026-03-30 14:21:24 +02:00
Test
d316539a91 fix: double editor column min-width from 400px to 800px
Rework: increase editor minimum width per feedback. Updates CSS,
layout constants, tests, and tauri.conf.json minWidth (800→1200).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:56:28 +02:00
Test
0f22475c20 fix: align title section with editor body text and stabilize top margin
Apply theme CSS variables to the editor scroll area so the title section
uses the same max-width as the BlockNote editor. Add matching margin-left
to the title row/separator for body text alignment. Move NoteIcon inside
the title row unconditionally so top margin is constant regardless of
emoji presence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:04:04 +02:00
Test
797c7b66b6 fix: ensure resize handle receives pointer events along full panel height
Add relative z-10 to ResizeHandle so it stacks above adjacent panel
content that was capturing pointer events due to DOM order.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:57:50 +02:00
Test
af7d79fe44 fix: enforce min-width per column with cascade shrink on window resize
Editor (400px), note list (220px), sidebar (180px), and inspector
(240px) now have CSS min-width constraints. Window minWidth set to
800px in Tauri config. Flex-shrink ratios create cascade order:
editor shrinks first, then note list, then sidebar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:29:57 +02:00
19 changed files with 34297 additions and 119 deletions

View File

@@ -46,7 +46,7 @@ BASE_URL="http://localhost:5201" npx playwright test tests/smoke/<slug>.spec.ts
Brian installs the release build and runs keyboard-only QA. Phase 1 must pass first or the task goes to To Rework.
Fire done signal only after Phase 1 passes — **two steps, both required**:
Fire done signal only after Phase 1 passes — **three steps, all required**:
```bash
# 1. Move task to In Review on Todoist
@@ -57,8 +57,75 @@ curl -s -X POST "https://api.todoist.com/api/v1/tasks/<task_id>/move" \
# 2. Notify Brian
openclaw system event --text "laputa-task-done:<task_id>" --mode now
# 3. Self-dispatch: pick next task autonomously
# Priority: To Rework first, then Open (sorted by Todoist priority p1→p4)
# Skip To Rework tasks whose last comment is ❌ QA failed with no follow-up human feedback
# If next task found: move it to In Progress and start working on it immediately
# If nothing available: exit — Brian's watchdog (laputa-dispatch cron, hourly) will restart you
python3 - <<'PYEOF'
import os, json, urllib.request, sys
token = os.environ["TODOIST_API_KEY"]
def get_tasks(section_id):
req = urllib.request.Request(
f"https://api.todoist.com/api/v1/tasks?project_id=6g3XjQFwv9V8Pxfv&section_id={section_id}",
headers={"Authorization": f"Bearer {token}"}
)
with urllib.request.urlopen(req) as r:
data = json.load(r)
return data if isinstance(data, list) else data.get("results", [])
def get_comments(task_id):
req = urllib.request.Request(
f"https://api.todoist.com/api/v1/comments?task_id={task_id}",
headers={"Authorization": f"Bearer {token}"}
)
with urllib.request.urlopen(req) as r:
data = json.load(r)
return data if isinstance(data, list) else data.get("results", [])
def skip_rework_task(task_id):
"""Return True if this To Rework task has no follow-up human feedback after the last QA fail."""
comments = get_comments(task_id)
last_fail_idx = -1
for i, c in enumerate(comments):
if isinstance(c, dict) and "\u274c" in c.get("content", ""):
last_fail_idx = i
if last_fail_idx == -1:
return False # No QA fail found — can proceed
after = comments[last_fail_idx + 1:]
human_keywords = ["feedback", "Luca", "Brian", "\u26a0\ufe0f", "fix", "should", "must", "rework"]
for c in after:
if any(kw.lower() in c.get("content", "").lower() for kw in human_keywords):
return False # Human weighed in — can proceed
return True # No human feedback yet — skip
for section_id, is_rework in [("6g6QqvR9rRpvJWvv", True), ("6g3XjWR832hVHhCM", False)]:
tasks = sorted(get_tasks(section_id), key=lambda t: t.get("priority", 4), reverse=True)
for task in tasks:
if is_rework and skip_rework_task(task["id"]):
continue
# Move to In Progress
req = urllib.request.Request(
f"https://api.todoist.com/api/v1/tasks/{task['id']}/move",
data=json.dumps({"section_id": "6g3XjWjfmJFcGgHM"}).encode(),
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
method="POST"
)
with urllib.request.urlopen(req) as r:
t = json.load(r)
print(f"NEXT_TASK_ID={t['id']}")
print(f"NEXT_TASK_TITLE={t['content']}")
sys.exit(0)
print("NO_TASKS")
PYEOF
```
**After running step 3:** if output contains `NEXT_TASK_ID=...`, read that task's full description from Todoist and implement it (repeating this entire CLAUDE.md flow). If output is `NO_TASKS`, you are done — exit cleanly.
## Project
Tauri v2 + React + TypeScript desktop app. Reads a vault of markdown files with YAML frontmatter.

View File

@@ -16,6 +16,8 @@
"title": "Laputa",
"width": 1400,
"height": 900,
"minWidth": 1200,
"minHeight": 400,
"resizable": true,
"fullscreen": false,
"titleBarStyle": "Overlay",

View File

@@ -16,7 +16,8 @@
}
.app__sidebar {
flex-shrink: 0;
flex-shrink: 1;
min-width: 180px;
display: flex;
flex-direction: column;
}
@@ -26,7 +27,8 @@
}
.app__note-list {
flex-shrink: 0;
flex-shrink: 10;
min-width: 220px;
display: flex;
flex-direction: column;
}
@@ -37,7 +39,7 @@
.app__editor {
flex: 1;
min-width: 0;
min-width: 800px;
min-height: 0;
overflow: hidden;
display: flex;

View File

@@ -25,6 +25,7 @@ import { useViewMode } from './hooks/useViewMode'
import { useEntryActions } from './hooks/useEntryActions'
import { useAppCommands } from './hooks/useAppCommands'
import { isEmoji } from './utils/emoji'
import { generateCommitMessage } from './utils/commitMessage'
import { useDialogs } from './hooks/useDialogs'
import { useVaultSwitcher } from './hooks/useVaultSwitcher'
import { useGitHistory } from './hooks/useGitHistory'
@@ -211,6 +212,7 @@ function App() {
}, [resolvedPath])
const commitFlow = useCommitFlow({ savePending: appSave.savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage, onPushRejected: autoSync.handlePushRejected })
const suggestedCommitMessage = useMemo(() => generateCommitMessage(vault.modifiedFiles), [vault.modifiedFiles])
const entryActions = useEntryActions({
entries: vault.entries, updateEntry: vault.updateEntry,
@@ -381,7 +383,7 @@ function App() {
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} selection={selection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} modifiedCount={vault.modifiedFiles.length} inboxCount={inboxCount} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} />
<Sidebar entries={vault.entries} selection={selection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} inboxCount={inboxCount} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
@@ -462,13 +464,13 @@ function App() {
/>
)}
<UpdateBanner status={updateStatus} actions={updateActions} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} suggestedMessage={suggestedCommitMessage} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
<ConflictResolverModal
open={dialogs.showConflictResolver}
fileStates={conflictResolver.fileStates}

View File

@@ -78,4 +78,30 @@ describe('CommitDialog', () => {
const { container } = render(<CommitDialog open={false} modifiedCount={3} onCommit={onCommit} onClose={onClose} />)
expect(container.querySelector('textarea')).toBeNull()
})
it('pre-populates message with suggestedMessage', () => {
render(<CommitDialog open={true} modifiedCount={3} suggestedMessage="Update alpha, beta" onCommit={onCommit} onClose={onClose} />)
const textarea = screen.getByPlaceholderText('Commit message...')
expect(textarea).toHaveValue('Update alpha, beta')
})
it('enables Commit button when suggestedMessage is provided', () => {
render(<CommitDialog open={true} modifiedCount={3} suggestedMessage="Update alpha" onCommit={onCommit} onClose={onClose} />)
expect(getCommitButton()).not.toBeDisabled()
})
it('submits suggestedMessage on Cmd+Enter without user edits', () => {
render(<CommitDialog open={true} modifiedCount={3} suggestedMessage="Update alpha" onCommit={onCommit} onClose={onClose} />)
const textarea = screen.getByPlaceholderText('Commit message...')
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
expect(onCommit).toHaveBeenCalledWith('Update alpha')
})
it('allows user to edit the suggested message', () => {
render(<CommitDialog open={true} modifiedCount={3} suggestedMessage="Update alpha" onCommit={onCommit} onClose={onClose} />)
const textarea = screen.getByPlaceholderText('Commit message...')
fireEvent.change(textarea, { target: { value: 'fix: corrected typo in alpha' } })
fireEvent.click(getCommitButton())
expect(onCommit).toHaveBeenCalledWith('fix: corrected typo in alpha')
})
})

View File

@@ -6,20 +6,21 @@ import { Badge } from '@/components/ui/badge'
interface CommitDialogProps {
open: boolean
modifiedCount: number
suggestedMessage?: string
onCommit: (message: string) => void
onClose: () => void
}
export function CommitDialog({ open, modifiedCount, onCommit, onClose }: CommitDialogProps) {
export function CommitDialog({ open, modifiedCount, suggestedMessage, onCommit, onClose }: CommitDialogProps) {
const [message, setMessage] = useState('')
const inputRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (open) {
setMessage('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setMessage(suggestedMessage ?? '') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open])
}, [open]) // eslint-disable-line react-hooks/exhaustive-deps -- only reset when dialog opens
const handleSubmit = () => {
const trimmed = message.trim()

View File

@@ -179,16 +179,35 @@
flex-shrink: 0;
}
.title-section__add-icon {
/* "Add icon" button above the title when no emoji — Notion-style */
padding-top: 24px;
margin-left: 8px;
min-height: 28px;
}
.title-section__row {
display: flex;
flex-direction: row;
align-items: flex-start;
padding-top: 8px;
margin-left: 8px;
}
/* No emoji: title aligns flush left (no indent for icon area) */
.title-section__row--no-icon {
margin-left: 0;
}
/* When emoji is present, restore top padding to the row itself */
.title-section__row:has(.note-icon-button--active) {
padding-top: 32px;
}
.title-section__separator {
border-bottom: 1px solid var(--border-primary, rgba(0, 0, 0, 0.08));
margin-top: 12px;
margin-left: 8px;
}
/* --- Note Icon Area --- */
@@ -227,7 +246,6 @@
font-size: 13px;
opacity: 0;
transition: opacity 0.15s;
margin-top: 16px;
}
.title-section:hover .note-icon-button--add,

View File

@@ -13,6 +13,7 @@ import { RawEditorView } from './RawEditorView'
import { countWords } from '../utils/wikilinks'
import { SingleEditorView } from './SingleEditorView'
import { isEmoji } from '../utils/emoji'
import { useEditorTheme } from '../hooks/useTheme'
interface Tab {
entry: VaultEntry
@@ -160,6 +161,7 @@ export function EditorContent({
}: EditorContentProps) {
// Look up trashed/archived from the latest vault entries, not the tab snapshot,
// so the banner appears regardless of navigation context.
const { cssVars } = useEditorTheme()
const freshEntry = activeTab ? entries.find(e => e.path === activeTab.entry.path) : undefined
const isTrashed = freshEntry?.trashed ?? activeTab?.entry.trashed ?? false
const isArchived = freshEntry?.archived ?? activeTab?.entry.archived ?? false
@@ -201,17 +203,19 @@ export function EditorContent({
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} latestContentRef={rawLatestContentRef} />
{showEditor && activeTab && (
<div className="editor-scroll-area">
<div className="editor-scroll-area" style={cssVars as React.CSSProperties}>
<div className="title-section">
{!emojiIcon && (
<NoteIcon
icon={null}
editable={!isTrashed}
onSetIcon={handleSetIcon}
onRemoveIcon={handleRemoveIcon}
/>
<div className="title-section__add-icon">
<NoteIcon
icon={null}
editable={!isTrashed}
onSetIcon={handleSetIcon}
onRemoveIcon={handleRemoveIcon}
/>
</div>
)}
<div className="title-section__row">
<div className={`title-section__row${emojiIcon ? '' : ' title-section__row--no-icon'}`}>
{emojiIcon && (
<NoteIcon
icon={emojiIcon}

View File

@@ -40,7 +40,7 @@ export function EditorRightPanel({
return (
<div
className="shrink-0 flex flex-col min-h-0"
style={{ width: inspectorWidth, height: '100%' }}
style={{ width: inspectorWidth, minWidth: 240, height: '100%' }}
>
<AiPanel
onClose={() => onToggleAIChat?.()}

View File

@@ -67,7 +67,7 @@ export function ResizeHandle({ onResize }: ResizeHandleProps) {
return (
<div
className="-ml-1 w-1 shrink-0 self-stretch cursor-col-resize bg-transparent transition-colors hover:bg-[var(--border)]"
className="relative z-10 -ml-1 w-1 shrink-0 self-stretch cursor-col-resize bg-transparent transition-colors hover:bg-[var(--border)]"
onMouseDown={handleMouseDown}
/>
)

View File

@@ -389,63 +389,11 @@ describe('Sidebar', () => {
expect(screen.queryByTitle('New Project')).not.toBeInTheDocument()
})
it('renders commit button even when no modified files', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} onCommitPush={() => {}} />)
expect(screen.getByText('Commit & Push')).toBeInTheDocument()
})
it('shows badge on commit button when modified files exist', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={3} onCommitPush={() => {}} />)
expect(screen.getByText('Commit & Push')).toBeInTheDocument()
const badges = screen.getAllByText('3')
expect(badges.length).toBeGreaterThanOrEqual(1)
})
it('shows Changes nav item when modifiedCount > 0', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={5} />)
expect(screen.getByText('Changes')).toBeInTheDocument()
})
it('hides Changes nav item when modifiedCount is 0', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={0} />)
it('does not render Changes or Pulse in sidebar', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.queryByText('Changes')).not.toBeInTheDocument()
})
it('calls onSelect with changes filter when clicking Changes', () => {
const onSelect = vi.fn()
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={onSelect} modifiedCount={3} />)
fireEvent.click(screen.getByText('Changes'))
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'changes' })
})
describe('Changes and Pulse in secondary bottom area', () => {
it('renders Changes outside the main top nav section', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={3} isGitVault />)
const changesEl = screen.getByText('Changes')
// Changes should be inside the secondary bottom area, not the top nav
const secondaryArea = changesEl.closest('[data-testid="sidebar-secondary"]')
expect(secondaryArea).not.toBeNull()
})
it('renders Pulse outside the main top nav section', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} isGitVault />)
const pulseEl = screen.getByText('Pulse')
const secondaryArea = pulseEl.closest('[data-testid="sidebar-secondary"]')
expect(secondaryArea).not.toBeNull()
})
it('does not render Changes or Pulse inside the top nav section', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={3} isGitVault />)
const topNav = screen.getByTestId('sidebar-top-nav')
expect(topNav.textContent).not.toContain('Changes')
expect(topNav.textContent).not.toContain('Pulse')
})
it('shows Changes badge count in secondary area', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={7} isGitVault />)
const secondaryArea = screen.getByTestId('sidebar-secondary')
expect(secondaryArea.textContent).toContain('7')
})
expect(screen.queryByText('Pulse')).not.toBeInTheDocument()
expect(screen.queryByText('Commit & Push')).not.toBeInTheDocument()
})
describe('dynamic custom type sections', () => {

View File

@@ -12,9 +12,9 @@ import {
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
FileText, Trash, Archive, CaretLeft, GitDiff, Pulse, Tray,
FileText, Trash, Archive, CaretLeft, Tray,
} from '@phosphor-icons/react'
import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react'
import { SlidersHorizontal } from 'lucide-react'
import {
type SectionGroup, isSelectionActive,
NavItem, SectionContent, type SectionContentProps, VisibilityPopover,
@@ -33,11 +33,8 @@ interface SidebarProps {
onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void
onRenameSection?: (typeName: string, label: string) => void
onToggleTypeVisibility?: (typeName: string) => void
modifiedCount?: number
inboxCount?: number
onCommitPush?: () => void
onCollapse?: () => void
isGitVault?: boolean
}
// --- Hooks ---
@@ -140,21 +137,6 @@ function SortableSection({ group, sectionProps }: {
)
}
function CommitButton({ modifiedCount, onClick }: { modifiedCount: number; onClick?: () => void }) {
if (!onClick) return null
return (
<div className="shrink-0 border-t border-border" style={{ padding: 12 }}>
<button className="flex w-full items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors" style={{ borderRadius: 6, gap: 6, padding: '8px 16px', border: 'none', cursor: 'pointer' }} onClick={onClick}>
<GitCommitHorizontal size={14} />
<span className="text-[13px] font-medium">Commit & Push</span>
{modifiedCount > 0 && (
<span className="text-white font-semibold" style={{ background: '#ffffff40', borderRadius: 9, padding: '0 6px', fontSize: 10 }}>{modifiedCount}</span>
)}
</button>
</div>
)
}
function SidebarTitleBar({ onCollapse }: { onCollapse?: () => void }) {
const { onMouseDown } = useDragRegion()
return (
@@ -223,7 +205,7 @@ export const Sidebar = memo(function Sidebar({
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
onToggleTypeVisibility,
modifiedCount = 0, inboxCount = 0, onCommitPush, onCollapse, isGitVault = false,
inboxCount = 0, onCollapse,
}: SidebarProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
@@ -333,14 +315,6 @@ export const Sidebar = memo(function Sidebar({
</DndContext>
</nav>
{/* Secondary area: Changes + Pulse */}
<div className="shrink-0 border-t border-border" data-testid="sidebar-secondary" style={{ padding: '4px 6px' }}>
{modifiedCount > 0 && (
<NavItem icon={GitDiff} label="Changes" count={modifiedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'changes' })} activeClassName="bg-[color:var(--accent-orange)]/10 text-[var(--accent-orange)]" badgeClassName="text-white" badgeStyle={{ background: 'var(--accent-orange)' }} onClick={() => onSelect({ kind: 'filter', filter: 'changes' })} compact />
)}
<NavItem icon={Pulse} label="Pulse" isActive={isSelectionActive(selection, { kind: 'filter', filter: 'pulse' })} disabled={!isGitVault} disabledTooltip="Pulse is only available for git-enabled vaults" onClick={isGitVault ? () => onSelect({ kind: 'filter', filter: 'pulse' }) : undefined} compact />
</div>
<CommitButton modifiedCount={modifiedCount} onClick={onCommitPush} />
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} onStartRename={handleStartRename} />
<CustomizeOverlay target={customizeTarget} typeEntryMap={typeEntryMap} innerRef={popoverRef} onCustomize={handleCustomize} onChangeTemplate={handleChangeTemplate} onClose={closeCustomizeTarget} />
</aside>

View File

@@ -182,18 +182,19 @@ describe('StatusBar', () => {
expect(screen.getByText('Connect GitHub repo')).toBeInTheDocument()
})
it('shows modified count when modifiedCount is > 0', () => {
it('shows Changes badge with count when modifiedCount is > 0', () => {
render(<StatusBar noteCount={100} modifiedCount={3} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
expect(screen.getByTestId('status-modified-count')).toBeInTheDocument()
expect(screen.getByText('3 pending')).toBeInTheDocument()
expect(screen.getByText('Changes')).toBeInTheDocument()
expect(screen.getByText('3')).toBeInTheDocument()
})
it('does not show modified count when modifiedCount is 0', () => {
it('does not show Changes badge when modifiedCount is 0', () => {
render(<StatusBar noteCount={100} modifiedCount={0} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
expect(screen.queryByTestId('status-modified-count')).not.toBeInTheDocument()
})
it('does not show modified count when modifiedCount is not provided', () => {
it('does not show Changes badge when modifiedCount is not provided', () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
expect(screen.queryByTestId('status-modified-count')).not.toBeInTheDocument()
})
@@ -313,4 +314,37 @@ describe('StatusBar', () => {
expect(screen.getByText(/1 behind/)).toBeInTheDocument()
})
it('shows Pulse badge in status bar', () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} isGitVault />)
expect(screen.getByTestId('status-pulse')).toBeInTheDocument()
expect(screen.getByText('Pulse')).toBeInTheDocument()
})
it('calls onClickPulse when clicking Pulse badge', () => {
const onClickPulse = vi.fn()
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} isGitVault onClickPulse={onClickPulse} />)
fireEvent.click(screen.getByTestId('status-pulse'))
expect(onClickPulse).toHaveBeenCalledOnce()
})
it('disables Pulse badge when isGitVault is false', () => {
const onClickPulse = vi.fn()
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} isGitVault={false} onClickPulse={onClickPulse} />)
fireEvent.click(screen.getByTestId('status-pulse'))
expect(onClickPulse).not.toHaveBeenCalled()
})
it('shows Commit & Push button next to Changes badge', () => {
const onCommitPush = vi.fn()
render(<StatusBar noteCount={100} modifiedCount={5} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCommitPush={onCommitPush} />)
expect(screen.getByTestId('status-commit-push')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('status-commit-push'))
expect(onCommitPush).toHaveBeenCalledOnce()
})
it('hides Commit & Push button when no modified files', () => {
render(<StatusBar noteCount={100} modifiedCount={0} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCommitPush={vi.fn()} />)
expect(screen.queryByTestId('status-commit-push')).not.toBeInTheDocument()
})
})

View File

@@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from 'react'
import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, X, Cpu, ArrowDown, GitBranch } from 'lucide-react'
import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, AlertTriangle, Loader2, GitCommitHorizontal, X, Cpu, ArrowDown, GitBranch } from 'lucide-react'
import { GitDiff, Pulse } from '@phosphor-icons/react'
import type { GitRemoteStatus, LastCommitInfo, SyncStatus } from '../types'
import type { McpStatus } from '../hooks/useMcpStatus'
import { openExternalUrl } from '../utils/url'
@@ -20,6 +21,9 @@ interface StatusBarProps {
onOpenLocalFolder?: () => void
onConnectGitHub?: () => void
onClickPending?: () => void
onClickPulse?: () => void
onCommitPush?: () => void
isGitVault?: boolean
hasGitHub?: boolean
syncStatus?: SyncStatus
lastSyncTime?: number | null
@@ -328,7 +332,7 @@ function ConflictBadge({ count, onClick }: { count: number; onClick?: () => void
)
}
function PendingBadge({ count, onClick }: { count: number; onClick?: () => void }) {
function ChangesBadge({ count, onClick, onCommitPush }: { count: number; onClick?: () => void; onCommitPush?: () => void }) {
if (count <= 0) return null
return (
<>
@@ -341,7 +345,50 @@ function PendingBadge({ count, onClick }: { count: number; onClick?: () => void
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
data-testid="status-modified-count"
><CircleDot size={13} style={{ color: 'var(--accent-orange)' }} />{count} pending</span>
>
<GitDiff size={13} style={{ color: 'var(--accent-orange)' }} />
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', background: 'var(--accent-orange)', color: '#fff', borderRadius: 9, padding: '0 5px', fontSize: 10, fontWeight: 600, minWidth: 16, lineHeight: '16px' }}>{count}</span>
Changes
</span>
{onCommitPush && (
<span
role="button"
onClick={onCommitPush}
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title="Commit & Push"
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
data-testid="status-commit-push"
>
<GitCommitHorizontal size={13} style={{ color: 'var(--accent-orange)' }} />
</span>
)}
</>
)
}
function PulseBadge({ onClick, disabled }: { onClick?: () => void; disabled?: boolean }) {
return (
<>
<span style={SEP_STYLE}>|</span>
<span
role={disabled ? undefined : 'button'}
onClick={disabled ? undefined : onClick}
style={{
...ICON_STYLE,
cursor: disabled ? 'not-allowed' : 'pointer',
padding: '2px 4px',
borderRadius: 3,
background: 'transparent',
opacity: disabled ? 0.4 : 1,
}}
title={disabled ? 'Pulse is only available for git-enabled vaults' : 'View pulse'}
onMouseEnter={disabled ? undefined : (e) => { e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={disabled ? undefined : (e) => { e.currentTarget.style.background = 'transparent' }}
data-testid="status-pulse"
>
<Pulse size={13} />Pulse
</span>
</>
)
}
@@ -381,7 +428,7 @@ function McpBadge({ status, onInstall }: { status: McpStatus; onInstall?: () =>
)
}
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, remoteStatus, onTriggerSync, onPullAndPush, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, onClickPulse, onCommitPush, isGitVault = false, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, remoteStatus, onTriggerSync, onPullAndPush, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
const [, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 30_000)
@@ -406,7 +453,8 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
<SyncBadge status={syncStatus} lastSyncTime={lastSyncTime} remoteStatus={remoteStatus} onTriggerSync={onTriggerSync} onPullAndPush={onPullAndPush} onOpenConflictResolver={onOpenConflictResolver} />
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
<PendingBadge count={modifiedCount} onClick={onClickPending} />
<ChangesBadge count={modifiedCount} onClick={onClickPending} onCommitPush={onCommitPush} />
<PulseBadge onClick={onClickPulse} disabled={!isGitVault} />
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>

View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useLayoutPanels, COLUMN_MIN_WIDTHS } from './useLayoutPanels'
describe('useLayoutPanels', () => {
it('exports column minimum widths', () => {
expect(COLUMN_MIN_WIDTHS.sidebar).toBe(180)
expect(COLUMN_MIN_WIDTHS.noteList).toBe(220)
expect(COLUMN_MIN_WIDTHS.editor).toBe(800)
expect(COLUMN_MIN_WIDTHS.inspector).toBe(240)
})
it('returns default widths', () => {
const { result } = renderHook(() => useLayoutPanels())
expect(result.current.sidebarWidth).toBe(250)
expect(result.current.noteListWidth).toBe(300)
expect(result.current.inspectorWidth).toBe(280)
})
it('clamps sidebar resize to minimum', () => {
const { result } = renderHook(() => useLayoutPanels())
act(() => result.current.handleSidebarResize(-500))
expect(result.current.sidebarWidth).toBe(COLUMN_MIN_WIDTHS.sidebar)
})
it('clamps note list resize to minimum', () => {
const { result } = renderHook(() => useLayoutPanels())
act(() => result.current.handleNoteListResize(-500))
expect(result.current.noteListWidth).toBe(COLUMN_MIN_WIDTHS.noteList)
})
it('clamps inspector resize to minimum', () => {
const { result } = renderHook(() => useLayoutPanels())
act(() => result.current.handleInspectorResize(500))
expect(result.current.inspectorWidth).toBe(COLUMN_MIN_WIDTHS.inspector)
})
it('clamps sidebar resize to maximum', () => {
const { result } = renderHook(() => useLayoutPanels())
act(() => result.current.handleSidebarResize(500))
expect(result.current.sidebarWidth).toBe(400)
})
it('clamps note list resize to maximum', () => {
const { result } = renderHook(() => useLayoutPanels())
act(() => result.current.handleNoteListResize(500))
expect(result.current.noteListWidth).toBe(500)
})
it('clamps inspector resize to maximum', () => {
const { result } = renderHook(() => useLayoutPanels())
act(() => result.current.handleInspectorResize(-500))
expect(result.current.inspectorWidth).toBe(500)
})
})

View File

@@ -1,12 +1,19 @@
import { useCallback, useState } from 'react'
export const COLUMN_MIN_WIDTHS = {
sidebar: 180,
noteList: 220,
editor: 800,
inspector: 240,
} as const
export function useLayoutPanels() {
const [sidebarWidth, setSidebarWidth] = useState(250)
const [noteListWidth, setNoteListWidth] = useState(300)
const [inspectorWidth, setInspectorWidth] = useState(280)
const [inspectorCollapsed, setInspectorCollapsed] = useState(false)
const handleSidebarResize = useCallback((delta: number) => setSidebarWidth((w) => Math.max(150, Math.min(400, w + delta))), [])
const handleNoteListResize = useCallback((delta: number) => setNoteListWidth((w) => Math.max(200, Math.min(500, w + delta))), [])
const handleInspectorResize = useCallback((delta: number) => setInspectorWidth((w) => Math.max(200, Math.min(500, w - delta))), [])
const handleSidebarResize = useCallback((delta: number) => setSidebarWidth((w) => Math.max(COLUMN_MIN_WIDTHS.sidebar, Math.min(400, w + delta))), [])
const handleNoteListResize = useCallback((delta: number) => setNoteListWidth((w) => Math.max(COLUMN_MIN_WIDTHS.noteList, Math.min(500, w + delta))), [])
const handleInspectorResize = useCallback((delta: number) => setInspectorWidth((w) => Math.max(COLUMN_MIN_WIDTHS.inspector, Math.min(500, w - delta))), [])
return { sidebarWidth, noteListWidth, inspectorWidth, inspectorCollapsed, setInspectorCollapsed, handleSidebarResize, handleNoteListResize, handleInspectorResize }
}

View File

@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest'
import { generateCommitMessage } from './commitMessage'
import type { ModifiedFile } from '../types'
function file(relativePath: string, status: ModifiedFile['status'] = 'modified'): ModifiedFile {
return { path: `/vault/${relativePath}`, relativePath, status }
}
describe('generateCommitMessage', () => {
it('returns empty string for no files', () => {
expect(generateCommitMessage([])).toBe('')
})
it('uses note title for a single modified file', () => {
expect(generateCommitMessage([file('winter-2026.md')])).toBe('Update winter-2026')
})
it('strips .md extension from the name', () => {
expect(generateCommitMessage([file('thoughts-on-testing.md')])).toBe('Update thoughts-on-testing')
})
it('uses basename for files in subdirectories', () => {
expect(generateCommitMessage([file('notes/deep/idea.md')])).toBe('Update idea')
})
it('lists 2 files comma-separated', () => {
const msg = generateCommitMessage([file('alpha.md'), file('beta.md')])
expect(msg).toBe('Update alpha, beta')
})
it('lists 3 files comma-separated', () => {
const msg = generateCommitMessage([
file('alpha.md'),
file('beta.md'),
file('gamma.md'),
])
expect(msg).toBe('Update alpha, beta, gamma')
})
it('uses count for 4+ files', () => {
const msg = generateCommitMessage([
file('a.md'),
file('b.md'),
file('c.md'),
file('d.md'),
])
expect(msg).toBe('Update 4 notes')
})
it('says "Add" for a single new/untracked file', () => {
expect(generateCommitMessage([file('new-idea.md', 'untracked')])).toBe('Add new-idea')
})
it('says "Add" for a single added file', () => {
expect(generateCommitMessage([file('new-idea.md', 'added')])).toBe('Add new-idea')
})
it('says "Delete" for a single deleted file', () => {
expect(generateCommitMessage([file('old-note.md', 'deleted')])).toBe('Delete old-note')
})
it('says "Rename" for a single renamed file', () => {
expect(generateCommitMessage([file('renamed.md', 'renamed')])).toBe('Rename renamed')
})
it('uses "Update" when statuses are mixed', () => {
const msg = generateCommitMessage([
file('alpha.md', 'modified'),
file('beta.md', 'untracked'),
])
expect(msg).toBe('Update alpha, beta')
})
})

View File

@@ -0,0 +1,33 @@
import type { ModifiedFile } from '../types'
const VERB_MAP: Record<string, string> = {
modified: 'Update',
added: 'Add',
untracked: 'Add',
deleted: 'Delete',
renamed: 'Rename',
}
const MAX_LISTED_FILES = 3
function noteName(relativePath: string): string {
const basename = relativePath.split('/').pop() ?? relativePath
return basename.replace(/\.md$/, '')
}
function verb(files: ModifiedFile[]): string {
const statuses = new Set(files.map((f) => f.status))
if (statuses.size === 1) return VERB_MAP[files[0].status] ?? 'Update'
return 'Update'
}
/** Generate a heuristic commit message from modified files. */
export function generateCommitMessage(files: ModifiedFile[]): string {
if (files.length === 0) return ''
const action = verb(files)
if (files.length <= MAX_LISTED_FILES) {
const names = files.map((f) => noteName(f.relativePath)).join(', ')
return `${action} ${names}`
}
return `${action} ${files.length} notes`
}

File diff suppressed because one or more lines are too long