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 };