diff --git a/.veritas-kanban/telemetry/events-2026-01-28.ndjson b/.veritas-kanban/telemetry/events-2026-01-28.ndjson index 85297fe2..0d7e3daf 100644 --- a/.veritas-kanban/telemetry/events-2026-01-28.ndjson +++ b/.veritas-kanban/telemetry/events-2026-01-28.ndjson @@ -22,3 +22,6 @@ {"type":"task.status_changed","taskId":"task_20260128_AaRWcX","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_I6-ezwZLEq7a","timestamp":"2026-01-28T02:34:13.758Z"} {"type":"task.status_changed","taskId":"task_20260128_qcy3FJ","project":"veritas-kanban","status":"todo","previousStatus":"done","id":"evt_-KkUZ32cY-cL","timestamp":"2026-01-28T02:34:56.532Z"} {"type":"task.status_changed","taskId":"task_20260128_qcy3FJ","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_Xad2QSppps2V","timestamp":"2026-01-28T02:36:31.643Z"} +{"type":"task.status_changed","taskId":"task_20260128_qcy3FJ","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_Ic_dBBsAdxN2","timestamp":"2026-01-28T02:41:20.724Z"} +{"type":"task.created","taskId":"task_20260128_2AhuSW","project":"veritas-kanban","status":"todo","id":"evt_ZSCSpCCeSO-x","timestamp":"2026-01-28T02:47:14.432Z"} +{"type":"task.status_changed","taskId":"task_20260128_2AhuSW","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_KgJY6-MonU7W","timestamp":"2026-01-28T02:47:18.135Z"} diff --git a/server/.veritas-kanban/activity.json b/server/.veritas-kanban/activity.json index 44e3966a..f03d672b 100644 --- a/server/.veritas-kanban/activity.json +++ b/server/.veritas-kanban/activity.json @@ -1,4 +1,38 @@ [ + { + "id": "activity_1769568438135_urtvgud9g", + "type": "status_changed", + "taskId": "task_20260128_2AhuSW", + "taskTitle": "US-910: Help context windows + keyboard shortcut fix", + "details": { + "from": "todo", + "status": "in-progress" + }, + "timestamp": "2026-01-28T02:47:18.135Z" + }, + { + "id": "activity_1769568434433_yi6801i5k", + "type": "task_created", + "taskId": "task_20260128_2AhuSW", + "taskTitle": "US-910: Help context windows + keyboard shortcut fix", + "details": { + "type": "code", + "priority": "high", + "project": "veritas-kanban" + }, + "timestamp": "2026-01-28T02:47:14.433Z" + }, + { + "id": "activity_1769568080724_pbup1xy0d", + "type": "status_changed", + "taskId": "task_20260128_qcy3FJ", + "taskTitle": "US-907: Apply template to existing task", + "details": { + "from": "in-progress", + "status": "done" + }, + "timestamp": "2026-01-28T02:41:20.724Z" + }, { "id": "activity_1769568075614_5u6dejgb9", "type": "task_updated", @@ -942,41 +976,5 @@ "project": "Sprint 8" }, "timestamp": "2026-01-26T17:57:55.527Z" - }, - { - "id": "activity_1769450275474_mprychh2p", - "type": "task_created", - "taskId": "task_20260126_pSMY_Y", - "taskTitle": "US-807: Dashboard Placement", - "details": { - "type": "code", - "priority": "medium", - "project": "Sprint 8" - }, - "timestamp": "2026-01-26T17:57:55.474Z" - }, - { - "id": "activity_1769450275420_csokqg065", - "type": "task_created", - "taskId": "task_20260126_caioxm", - "taskTitle": "US-806: Dashboard Component", - "details": { - "type": "code", - "priority": "high", - "project": "Sprint 8" - }, - "timestamp": "2026-01-26T17:57:55.420Z" - }, - { - "id": "activity_1769450275365_4akuovlq5", - "type": "task_created", - "taskId": "task_20260126_HCSqiT", - "taskTitle": "US-805: Metrics API", - "details": { - "type": "code", - "priority": "high", - "project": "Sprint 8" - }, - "timestamp": "2026-01-26T17:57:55.365Z" } ] \ No newline at end of file diff --git a/web/src/components/settings/SettingsDialog.tsx b/web/src/components/settings/SettingsDialog.tsx index 771e0d37..d76bc4c5 100644 --- a/web/src/components/settings/SettingsDialog.tsx +++ b/web/src/components/settings/SettingsDialog.tsx @@ -41,7 +41,7 @@ import { useDeleteTemplate, type TaskTemplate, } from '@/hooks/useTemplates'; -import { Plus, Trash2, Check, X, Loader2, FolderGit2, Bot, Star, FileText, Download, Upload } from 'lucide-react'; +import { Plus, Trash2, Check, X, Loader2, FolderGit2, Bot, Star, FileText, Download, Upload, HelpCircle, Info } from 'lucide-react'; import type { RepoConfig, AgentConfig, AgentType, TaskType, TaskPriority } from '@veritas-kanban/shared'; import { cn } from '@/lib/utils'; import { TEMPLATE_CATEGORIES, getCategoryIcon, getCategoryLabel } from '@/lib/template-categories'; @@ -501,6 +501,7 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { const { data: templates, isLoading: templatesLoading } = useTemplates(); const [showAddForm, setShowAddForm] = useState(false); const [showAddTemplateForm, setShowAddTemplateForm] = useState(false); + const [showTemplateHelp, setShowTemplateHelp] = useState(false); const updateAgents = useUpdateAgents(); const setDefaultAgent = useSetDefaultAgent(); const createTemplate = useCreateTemplate(); @@ -643,7 +644,17 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { {/* Templates Section */}
-

Task Templates

+
+

Task Templates

+ +
+ {/* Help Section */} + {showTemplateHelp && ( +
+
+ +
+

Template Guide

+ +
+
+ Simple Templates: +

Create templates with predefined task fields (type, priority, project) and description. Add subtask templates that will be created automatically.

+
+ +
+ Categories: +

Organize templates by category (Bug 🐛, Feature ✨, Sprint 🔄, etc.) for easier discovery.

+
+ +
+ Variables: +

Use {'{{date}}'}, {'{{time}}'}, {'{{author}}'}, or {'{{project}}'} for automatic values. Custom variables like {'{{ticketId}}'} will prompt for input.

+
+ +
+ Blueprint Templates: +

Advanced templates that create multiple tasks with dependencies. Perfect for multi-step workflows like sprint planning or feature launches.

+
+ +
+ Import/Export: +

Share templates as JSON files. Export your templates to back them up or share with others.

+
+
+
+
+
+ )} + {/* Hidden file input for import */} >({}); const [requiredCustomVars, setRequiredCustomVars] = useState([]); const [categoryFilter, setCategoryFilter] = useState('all'); + const [showHelp, setShowHelp] = useState(false); const createTask = useCreateTask(); const { data: templates } = useTemplates(); @@ -246,10 +247,39 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps) {/* Template selector */} {templates && templates.length > 0 && (
-
- - +
+
+ + +
+
+ + {/* Help Section */} + {showHelp && ( +
+
+ +
+

Using Templates

+
    +
  • • Simple templates pre-fill task fields and can include subtasks
  • +
  • • Variables like {'{{date}}'} or {'{{author}}'} are replaced when creating the task
  • +
  • • Custom variables (e.g., {'{{bugId}}'}) prompt you for values
  • +
  • • Blueprint templates create multiple linked tasks with dependencies
  • +
+
+
+
+ )} All diff --git a/web/src/components/ui/toast.tsx b/web/src/components/ui/toast.tsx new file mode 100644 index 00000000..80482471 --- /dev/null +++ b/web/src/components/ui/toast.tsx @@ -0,0 +1,112 @@ +import * as React from 'react'; +import * as ToastPrimitives from '@radix-ui/react-toast'; +import { cn } from '@/lib/utils'; +import { X } from 'lucide-react'; + +const ToastProvider = ToastPrimitives.Provider; + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastViewport.displayName = ToastPrimitives.Viewport.displayName; + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ); +}); +Toast.displayName = ToastPrimitives.Root.displayName; + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastAction.displayName = ToastPrimitives.Action.displayName; + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +ToastClose.displayName = ToastPrimitives.Close.displayName; + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastTitle.displayName = ToastPrimitives.Title.displayName; + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastDescription.displayName = ToastPrimitives.Description.displayName; + +type ToastProps = React.ComponentPropsWithoutRef; + +type ToastActionElement = React.ReactElement; + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +}; diff --git a/web/src/components/ui/toaster.tsx b/web/src/components/ui/toaster.tsx index a5cd9385..633a6787 100644 --- a/web/src/components/ui/toaster.tsx +++ b/web/src/components/ui/toaster.tsx @@ -1,4 +1,33 @@ -// Placeholder toaster - we'll implement this properly later +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from '@/components/ui/toast'; +import { useToast } from '@/hooks/useToast'; + export function Toaster() { - return null; + const { toasts } = useToast(); + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ); + })} + +
+ ); } diff --git a/web/src/hooks/useKeyboard.tsx b/web/src/hooks/useKeyboard.tsx index f621bb65..67da94b8 100644 --- a/web/src/hooks/useKeyboard.tsx +++ b/web/src/hooks/useKeyboard.tsx @@ -1,5 +1,6 @@ import { createContext, useContext, useCallback, useEffect, useState, useRef, type ReactNode } from 'react'; import type { Task, TaskStatus } from '@veritas-kanban/shared'; +import { toast } from './useToast'; interface KeyboardContextValue { // Dialog triggers @@ -90,7 +91,8 @@ export function KeyboardProvider({ children }: { children: ReactNode }) { } // Ignore if a dialog is open (except Escape) - const dialogOpen = document.querySelector('[role="dialog"]'); + // Check for visible dialogs only (shadcn uses data-state attribute) + const dialogOpen = document.querySelector('[role="dialog"][data-state="open"]'); if (dialogOpen && e.key !== 'Escape') { return; } @@ -152,10 +154,16 @@ export function KeyboardProvider({ children }: { children: ReactNode }) { case '2': case '3': case '4': + e.preventDefault(); if (selectedTaskId && onMoveTaskRef.current) { - e.preventDefault(); const newStatus = STATUS_MAP[e.key]; onMoveTaskRef.current(selectedTaskId, newStatus); + } else { + // Show toast when no task is selected + toast({ + title: 'No task selected', + description: 'Use j/k or arrow keys to select a task first', + }); } break; } diff --git a/web/src/hooks/useToast.tsx b/web/src/hooks/useToast.tsx new file mode 100644 index 00000000..368e31ae --- /dev/null +++ b/web/src/hooks/useToast.tsx @@ -0,0 +1,190 @@ +import * as React from 'react'; + +type ToastActionElement = React.ReactElement; + +type ToastProps = { + id: string; + title?: string; + description?: string; + action?: ToastActionElement; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}; + +const TOAST_LIMIT = 3; +const TOAST_REMOVE_DELAY = 5000; + +type ToasterToast = ToastProps & { + id: string; +}; + +const actionTypes = { + ADD_TOAST: 'ADD_TOAST', + UPDATE_TOAST: 'UPDATE_TOAST', + DISMISS_TOAST: 'DISMISS_TOAST', + REMOVE_TOAST: 'REMOVE_TOAST', +} as const; + +let count = 0; + +function genId() { + count = (count + 1) % Number.MAX_VALUE; + return count.toString(); +} + +type Action = + | { + type: typeof actionTypes.ADD_TOAST; + toast: ToasterToast; + } + | { + type: typeof actionTypes.UPDATE_TOAST; + toast: Partial; + } + | { + type: typeof actionTypes.DISMISS_TOAST; + toastId?: ToasterToast['id']; + } + | { + type: typeof actionTypes.REMOVE_TOAST; + toastId?: ToasterToast['id']; + }; + +interface State { + toasts: ToasterToast[]; +} + +const toastTimeouts = new Map>(); + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return; + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId); + dispatch({ + type: 'REMOVE_TOAST', + toastId: toastId, + }); + }, TOAST_REMOVE_DELAY); + + toastTimeouts.set(toastId, timeout); +}; + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case 'ADD_TOAST': + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + }; + + case 'UPDATE_TOAST': + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + }; + + case 'DISMISS_TOAST': { + const { toastId } = action; + + if (toastId) { + addToRemoveQueue(toastId); + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id); + }); + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + }; + } + case 'REMOVE_TOAST': + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + }; + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + }; + } +}; + +const listeners: Array<(state: State) => void> = []; + +let memoryState: State = { toasts: [] }; + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action); + listeners.forEach((listener) => { + listener(memoryState); + }); +} + +type Toast = Omit; + +function toast({ ...props }: Toast) { + const id = genId(); + + const update = (props: ToasterToast) => + dispatch({ + type: 'UPDATE_TOAST', + toast: { ...props, id }, + }); + const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id }); + + dispatch({ + type: 'ADD_TOAST', + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss(); + }, + }, + }); + + return { + id: id, + dismiss, + update, + }; +} + +function useToast() { + const [state, setState] = React.useState(memoryState); + + React.useEffect(() => { + listeners.push(setState); + return () => { + const index = listeners.indexOf(setState); + if (index > -1) { + listeners.splice(index, 1); + } + }; + }, [state]); + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }), + }; +} + +export { useToast, toast };