feat(US-910): Help context windows + keyboard shortcut fix

This commit is contained in:
Brad Groux 2026-01-27 20:50:26 -06:00
parent f87fe0f8af
commit b243728f08
8 changed files with 466 additions and 46 deletions

View file

@ -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"}

View file

@ -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"
}
]

View file

@ -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 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Task Templates</h3>
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium">Task Templates</h3>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => setShowTemplateHelp(!showTemplateHelp)}
>
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
<div className="flex gap-2">
<Button
variant="outline"
@ -676,6 +687,45 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
</div>
</div>
{/* Help Section */}
{showTemplateHelp && (
<div className="p-3 rounded-md bg-muted/50 border border-muted-foreground/20 text-sm space-y-3">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0" />
<div className="space-y-2">
<p className="font-medium text-sm">Template Guide</p>
<div className="text-xs text-muted-foreground space-y-1.5">
<div>
<strong className="text-foreground">Simple Templates:</strong>
<p className="mt-0.5">Create templates with predefined task fields (type, priority, project) and description. Add subtask templates that will be created automatically.</p>
</div>
<div>
<strong className="text-foreground">Categories:</strong>
<p className="mt-0.5">Organize templates by category (Bug 🐛, Feature ✨, Sprint 🔄, etc.) for easier discovery.</p>
</div>
<div>
<strong className="text-foreground">Variables:</strong>
<p className="mt-0.5">Use <code className="px-1 py-0.5 rounded bg-muted">{'{{date}}'}</code>, <code className="px-1 py-0.5 rounded bg-muted">{'{{time}}'}</code>, <code className="px-1 py-0.5 rounded bg-muted">{'{{author}}'}</code>, or <code className="px-1 py-0.5 rounded bg-muted">{'{{project}}'}</code> for automatic values. Custom variables like <code className="px-1 py-0.5 rounded bg-muted">{'{{ticketId}}'}</code> will prompt for input.</p>
</div>
<div>
<strong className="text-foreground">Blueprint Templates:</strong>
<p className="mt-0.5">Advanced templates that create multiple tasks with dependencies. Perfect for multi-step workflows like sprint planning or feature launches.</p>
</div>
<div>
<strong className="text-foreground">Import/Export:</strong>
<p className="mt-0.5">Share templates as JSON files. Export your templates to back them up or share with others.</p>
</div>
</div>
</div>
</div>
</div>
)}
{/* Hidden file input for import */}
<input
ref={fileInputRef}

View file

@ -21,7 +21,7 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useCreateTask } from '@/hooks/useTasks';
import { useTemplates, type TaskTemplate } from '@/hooks/useTemplates';
import type { TaskType, TaskPriority, Subtask } from '@veritas-kanban/shared';
import { FileText, X, Check, AlertCircle } from 'lucide-react';
import { FileText, X, Check, AlertCircle, HelpCircle, Info } from 'lucide-react';
import { nanoid } from 'nanoid';
import {
interpolateVariables,
@ -46,6 +46,7 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
const [customVars, setCustomVars] = useState<Record<string, string>>({});
const [requiredCustomVars, setRequiredCustomVars] = useState<string[]>([]);
const [categoryFilter, setCategoryFilter] = useState<string>('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 && (
<div className="border-b pb-2">
<div className="flex items-center gap-2 mb-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm">Template</Label>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm">Template</Label>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => setShowHelp(!showHelp)}
>
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
{/* Help Section */}
{showHelp && (
<div className="mb-3 p-3 rounded-md bg-muted/50 border border-muted-foreground/20 text-sm space-y-2">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0" />
<div className="space-y-1.5">
<p className="font-medium text-sm">Using Templates</p>
<ul className="space-y-1 text-xs text-muted-foreground">
<li>• <strong>Simple templates</strong> pre-fill task fields and can include subtasks</li>
<li>• <strong>Variables</strong> like <code className="px-1 py-0.5 rounded bg-muted">{'{{date}}'}</code> or <code className="px-1 py-0.5 rounded bg-muted">{'{{author}}'}</code> are replaced when creating the task</li>
<li>• <strong>Custom variables</strong> (e.g., <code className="px-1 py-0.5 rounded bg-muted">{'{{bugId}}'}</code>) prompt you for values</li>
<li>• <strong>Blueprint templates</strong> create multiple linked tasks with dependencies</li>
</ul>
</div>
</div>
</div>
)}
<Tabs value={categoryFilter} onValueChange={setCategoryFilter}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="all" className="text-xs">All</TabsTrigger>

View file

@ -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<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root>
>(({ className, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-4 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
'bg-background border-border',
className
)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
className
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn('text-sm font-semibold', className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View file

@ -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 (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View file

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

190
web/src/hooks/useToast.tsx Normal file
View file

@ -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<ToasterToast>;
}
| {
type: typeof actionTypes.DISMISS_TOAST;
toastId?: ToasterToast['id'];
}
| {
type: typeof actionTypes.REMOVE_TOAST;
toastId?: ToasterToast['id'];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
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<ToasterToast, 'id'>;
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<State>(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 };