Skip to content
Merged
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
39 changes: 39 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { TaskViewsPanel } from './components/TaskViewsPanel';
import { TaskDetail } from './components/TaskDetail';
import { ResourceTasksPanel } from './components/ResourceTasksPanel';
import { CommandPalette } from './components/CommandPalette';
import { QuickAddModal } from './components/QuickAddModal';
import { MobileGate } from './components/MobileGate';
import { useProjects } from './hooks/useProjects';
import { useTasks } from './hooks/useTasks';
Expand Down Expand Up @@ -105,6 +106,7 @@ function AppShell() {
const [viewPreset, setViewPreset] = usePersistedState<BoardViewPreset>('baton.board.viewPreset', 'all');
const [releaseTarget, setReleaseTarget] = usePersistedState<string>('baton.board.releaseTarget', '');
const [paletteOpen, setPaletteOpen] = useState(false);
const [quickAddOpen, setQuickAddOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const [registryOpen, setRegistryOpen] = useState(false);
Expand Down Expand Up @@ -207,6 +209,27 @@ function AppShell() {
return () => window.removeEventListener('keydown', handler);
}, []);

// Global T hotkey to open Quick Add modal.
React.useEffect(() => {
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
const isInput = target && (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable
);
if (isInput) return;

if ((e.key === 't' || e.key === 'T') && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
setQuickAddOpen(true);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);

React.useEffect(() => {
const match = window.location.pathname.match(/^\/(tasks|users|agents)\/([^/]+)$/);
if (!match) return;
Expand Down Expand Up @@ -278,6 +301,7 @@ function AppShell() {
onOpenRegistry={() => setRegistryOpen(true)}
onOpenViews={() => setViewsOpen(true)}
onOpenPalette={() => setPaletteOpen(true)}
onOpenQuickAdd={() => setQuickAddOpen(true)}
onOpenSidebar={() => setMobileSidebarOpen(true)}
density={density}
onToggleDensity={() => setDensity(d => d === 'comfortable' ? 'compact' : 'comfortable')}
Expand Down Expand Up @@ -483,6 +507,21 @@ function AppShell() {
onClose={() => setPaletteOpen(false)}
/>
)}
<QuickAddModal
isOpen={quickAddOpen}
projects={visibleProjects}
selectedProjectId={selectedProjectId}
users={users}
agents={agents}
onClose={() => setQuickAddOpen(false)}
onSubmit={async (data) => {
if (data.projectId !== selectedProjectId) {
return await api.createTask(data);
}
return await createTask(data);
}}
onOpenDetail={(task) => setLinkedTask(task)}
/>
</div>
);
}
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface Props {
onOpenRegistry: () => void;
onOpenViews: () => void;
onOpenPalette: () => void;
onOpenQuickAdd?: () => void;
onOpenSidebar: () => void;
density: 'comfortable' | 'compact';
onToggleDensity: () => void;
Expand All @@ -41,6 +42,7 @@ export function Header({
onOpenRegistry,
onOpenViews,
onOpenPalette,
onOpenQuickAdd,
onOpenSidebar,
density,
onToggleDensity,
Expand Down Expand Up @@ -206,6 +208,19 @@ export function Header({
/>
</div>

{onOpenQuickAdd && (
<button
type="button"
onClick={onOpenQuickAdd}
title="Quick add task (T)"
aria-label="Quick add task"
className="inline-flex items-center gap-1.5 text-xs text-amber-400 bg-amber-500/10 border border-amber-500/30 rounded px-2.5 py-1 hover:bg-amber-500/20 transition-colors font-medium"
>
<span>+ Quick Add</span>
<span className="text-[10px] bg-amber-500/20 text-amber-300 px-1 rounded font-mono">T</span>
</button>
)}

<button
type="button"
onClick={onOpenPalette}
Expand Down
54 changes: 54 additions & 0 deletions frontend/src/components/QuickAddModal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';

describe('QuickAddModal smart syntax parser', () => {
const parseInlineSyntax = (text: string, defaultPriority: string = 'medium', defaultEstimate: number | null = null) => {
let clean = text;
let parsedPriority: string | null = null;
let parsedEstimate: number | null = null;

const prioMatch = clean.match(/(?:^|\s)!(critical|high|medium|low)(?=\s|$)/i);
if (prioMatch) {
parsedPriority = prioMatch[1].toLowerCase();
clean = clean.replace(prioMatch[0], ' ');
}

const estMatch = clean.match(/(?:^|\s)@(\d+(?:\.\d+)?)(?:pt|h)?(?=\s|$)/i);
if (estMatch) {
parsedEstimate = parseFloat(estMatch[1]);
clean = clean.replace(estMatch[0], '');
}

return {
title: clean.trim().replace(/\s+/g, ' '),
priority: parsedPriority || defaultPriority,
estimate: parsedEstimate !== null ? parsedEstimate : defaultEstimate,
};
};

it('parses inline priority !high and estimate @5pt from task title', () => {
const result = parseInlineSyntax('Implement OAuth authorization flow !high @5pt');
expect(result.title).toBe('Implement OAuth authorization flow');
expect(result.priority).toBe('high');
expect(result.estimate).toBe(5);
});

it('parses !critical priority with @3 estimate', () => {
const result = parseInlineSyntax('Fix production memory leak !critical @3');
expect(result.title).toBe('Fix production memory leak');
expect(result.priority).toBe('critical');
expect(result.estimate).toBe(3);
});

it('retains default priority and estimate when no inline tags present', () => {
const result = parseInlineSyntax('Refactor API handlers', 'medium', 2);
expect(result.title).toBe('Refactor API handlers');
expect(result.priority).toBe('medium');
expect(result.estimate).toBe(2);
});

it('does not treat package versions as estimate shortcuts', () => {
const result = parseInlineSyntax('Upgrade React@18');
expect(result.title).toBe('Upgrade React@18');
expect(result.estimate).toBeNull();
});
});
Loading
Loading