feat(US-907): Apply template to existing task

This commit is contained in:
Brad Groux 2026-01-27 20:41:20 -06:00
parent a81dfecadf
commit f87fe0f8af
7 changed files with 644 additions and 152 deletions

View file

@ -19,3 +19,6 @@
{"type":"task.status_changed","taskId":"task_20260128_9Jlp1m","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_lsPTUFTmn8gW","timestamp":"2026-01-28T02:30:42.734Z"}
{"type":"task.status_changed","taskId":"task_20260128_qcy3FJ","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_SZlouG1WUBpJ","timestamp":"2026-01-28T02:32:53.102Z"}
{"type":"task.status_changed","taskId":"task_20260128_dKzctW","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_v40ijOFUT32J","timestamp":"2026-01-28T02:32:53.111Z"}
{"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"}

View file

@ -1,4 +1,121 @@
[
{
"id": "activity_1769568075614_5u6dejgb9",
"type": "task_updated",
"taskId": "task_20260128_qcy3FJ",
"taskTitle": "US-907: Apply template to existing task",
"timestamp": "2026-01-28T02:41:15.614Z"
},
{
"id": "activity_1769568028093_94k9mk402",
"type": "task_updated",
"taskId": "task_20260128_qcy3FJ",
"taskTitle": "US-907: Apply template to existing task",
"timestamp": "2026-01-28T02:40:28.093Z"
},
{
"id": "activity_1769567987042_i1odnhqgj",
"type": "task_updated",
"taskId": "task_20260128_qcy3FJ",
"taskTitle": "US-907: Apply template to existing task",
"timestamp": "2026-01-28T02:39:47.042Z"
},
{
"id": "activity_1769567916278_x73ysfm8g",
"type": "task_updated",
"taskId": "task_20260128_qcy3FJ",
"taskTitle": "US-907: Apply template to existing task",
"timestamp": "2026-01-28T02:38:36.278Z"
},
{
"id": "activity_1769567791644_0ze9p6hxo",
"type": "status_changed",
"taskId": "task_20260128_qcy3FJ",
"taskTitle": "US-907: Apply template to existing task",
"details": {
"from": "todo",
"status": "in-progress"
},
"timestamp": "2026-01-28T02:36:31.644Z"
},
{
"id": "activity_1769567696533_qfmr8x62s",
"type": "status_changed",
"taskId": "task_20260128_qcy3FJ",
"taskTitle": "US-907: Apply template to existing task",
"details": {
"from": "done",
"status": "todo"
},
"timestamp": "2026-01-28T02:34:56.533Z"
},
{
"id": "activity_1769567681146_fxg33ratb",
"type": "task_updated",
"taskId": "task_20260128_AaRWcX",
"taskTitle": "US-909: Enhanced template UI",
"timestamp": "2026-01-28T02:34:41.146Z"
},
{
"id": "activity_1769567681136_tobnbsg2u",
"type": "task_updated",
"taskId": "task_20260128_dKzctW",
"taskTitle": "US-908: Template import/export",
"timestamp": "2026-01-28T02:34:41.136Z"
},
{
"id": "activity_1769567681127_rt6kve88l",
"type": "task_updated",
"taskId": "task_20260128_9Jlp1m",
"taskTitle": "US-906: Agent & dependency defaults",
"timestamp": "2026-01-28T02:34:41.127Z"
},
{
"id": "activity_1769567681110_p30y0xvku",
"type": "task_updated",
"taskId": "task_20260128_8ro0pA",
"taskTitle": "US-905: Multi-task blueprints",
"timestamp": "2026-01-28T02:34:41.110Z"
},
{
"id": "activity_1769567681102_e8wwvvm0q",
"type": "task_updated",
"taskId": "task_20260128_dvaLco",
"taskTitle": "US-904: Template categories",
"timestamp": "2026-01-28T02:34:41.102Z"
},
{
"id": "activity_1769567681092_x1p500byz",
"type": "task_updated",
"taskId": "task_20260128_nyIFMc",
"taskTitle": "US-903: Variable interpolation",
"timestamp": "2026-01-28T02:34:41.092Z"
},
{
"id": "activity_1769567681083_7plxvn7o7",
"type": "task_updated",
"taskId": "task_20260128_z6OIb_",
"taskTitle": "US-902: Subtask templates",
"timestamp": "2026-01-28T02:34:41.083Z"
},
{
"id": "activity_1769567681073_70343o38y",
"type": "task_updated",
"taskId": "task_20260128_fL0JN1",
"taskTitle": "US-901: Enhanced template schema",
"timestamp": "2026-01-28T02:34:41.073Z"
},
{
"id": "activity_1769567653758_gip0r8h6y",
"type": "status_changed",
"taskId": "task_20260128_AaRWcX",
"taskTitle": "US-909: Enhanced template UI",
"details": {
"from": "todo",
"status": "done"
},
"timestamp": "2026-01-28T02:34:13.758Z"
},
{
"id": "activity_1769567573111_d3sd566d0",
"type": "status_changed",
@ -861,155 +978,5 @@
"project": "Sprint 8"
},
"timestamp": "2026-01-26T17:57:55.365Z"
},
{
"id": "activity_1769450252743_xx89yq6ne",
"type": "task_created",
"taskId": "task_20260126_8SKJ8t",
"taskTitle": "US-804: Token Usage Tracking",
"details": {
"type": "code",
"priority": "medium",
"project": "Sprint 8"
},
"timestamp": "2026-01-26T17:57:32.743Z"
},
{
"id": "activity_1769450252689_dejjxuezs",
"type": "task_created",
"taskId": "task_20260126_aBQTa1",
"taskTitle": "US-803: Emit Run Events",
"details": {
"type": "code",
"priority": "high",
"project": "Sprint 8"
},
"timestamp": "2026-01-26T17:57:32.689Z"
},
{
"id": "activity_1769450252634_w6utau64q",
"type": "task_created",
"taskId": "task_20260126_kgLjM8",
"taskTitle": "US-802: Emit Task Events",
"details": {
"type": "code",
"priority": "high",
"project": "Sprint 8"
},
"timestamp": "2026-01-26T17:57:32.634Z"
},
{
"id": "activity_1769450237943_armw0090q",
"type": "task_created",
"taskId": "task_20260126_desyd5",
"taskTitle": "US-801: Telemetry Service",
"details": {
"type": "code",
"priority": "high",
"project": "Sprint 8"
},
"timestamp": "2026-01-26T17:57:17.943Z"
},
{
"id": "activity_1769449288760_hzmmgfxfi",
"type": "task_deleted",
"taskId": "task_20260126_9VIZY1",
"taskTitle": "Test task from CLI",
"timestamp": "2026-01-26T17:41:28.760Z"
},
{
"id": "activity_1769449185820_cctph1fac",
"type": "task_deleted",
"taskId": "task_20260126_tsDVOq",
"taskTitle": "Test feature branch",
"timestamp": "2026-01-26T17:39:45.820Z"
},
{
"id": "activity_1769449175512_6lvnf3bgl",
"type": "task_updated",
"taskId": "task_20260126_tsDVOq",
"taskTitle": "Test feature branch",
"timestamp": "2026-01-26T17:39:35.512Z"
},
{
"id": "activity_1769448961158_7hmywa016",
"type": "status_changed",
"taskId": "task_20260126_9VIZY1",
"taskTitle": "Test task from CLI",
"details": {
"from": "archived",
"status": "done"
},
"timestamp": "2026-01-26T17:36:01.158Z"
},
{
"id": "activity_1769448955837_m815ex2dh",
"type": "task_archived",
"taskId": "task_20260126_9VIZY1",
"taskTitle": "Test task from CLI",
"timestamp": "2026-01-26T17:35:55.837Z"
},
{
"id": "activity_1769448944900_dm04m4zyu",
"type": "status_changed",
"taskId": "task_20260126_9VIZY1",
"taskTitle": "Test task from CLI",
"details": {
"from": "in-progress",
"status": "done"
},
"timestamp": "2026-01-26T17:35:44.900Z"
},
{
"id": "activity_1769448691266_xbovau2j5",
"type": "task_updated",
"taskId": "task_20260126_tsDVOq",
"taskTitle": "Test feature branch",
"timestamp": "2026-01-26T17:31:31.266Z"
},
{
"id": "activity_1769448671292_o56r391ij",
"type": "task_created",
"taskId": "task_20260126_tsDVOq",
"taskTitle": "Test feature branch",
"details": {
"type": "code",
"priority": "medium",
"project": "test"
},
"timestamp": "2026-01-26T17:31:11.292Z"
},
{
"id": "activity_1769448643552_50ghtkm2u",
"type": "status_changed",
"taskId": "task_20260126_9VIZY1",
"taskTitle": "Test task from CLI",
"details": {
"from": "todo",
"status": "in-progress"
},
"timestamp": "2026-01-26T17:30:43.552Z"
},
{
"id": "activity_1769448480165_c8bcel8bk",
"type": "task_created",
"taskId": "task_20260126_9VIZY1",
"taskTitle": "Test task from CLI",
"details": {
"type": "code",
"priority": "high"
},
"timestamp": "2026-01-26T17:28:00.165Z"
},
{
"id": "activity_1769447559137_w5so806no",
"type": "status_changed",
"taskId": "task_20260126_PcoH5E",
"taskTitle": "US-711: Running indicator on task cards",
"details": {
"from": "todo",
"status": "done"
},
"timestamp": "2026-01-26T17:12:39.137Z"
}
]

View file

@ -509,6 +509,36 @@ router.get('/:id/worktree/open', async (req, res) => {
}
});
// === Template Application Routes ===
// POST /api/tasks/:id/apply-template - Apply template to existing task
router.post('/:id/apply-template', async (req, res) => {
try {
const { templateId, templateName, fieldsChanged } = req.body;
if (!templateId) {
return res.status(400).json({ error: 'Template ID is required' });
}
const task = await taskService.getTask(req.params.id);
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}
// Log activity for template application
await activityService.logActivity('template_applied', task.id, task.title, {
templateId,
templateName: templateName || 'Unknown',
fieldsChanged: fieldsChanged || [],
});
res.json({ success: true });
} catch (error: any) {
console.error('Error logging template application:', error);
res.status(500).json({ error: error.message || 'Failed to log template application' });
}
});
// === Time Tracking Routes ===
// POST /api/tasks/:id/time/start - Start timer for a task

View file

@ -13,7 +13,8 @@ export type ActivityType =
| 'task_deleted'
| 'worktree_created'
| 'worktree_merged'
| 'project_archived';
| 'project_archived'
| 'template_applied';
export interface Activity {
id: string;

View file

@ -0,0 +1,462 @@
import { useState, useMemo } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Switch } from '@/components/ui/switch';
import { useTemplates } from '@/hooks/useTemplates';
import { useUpdateTask } from '@/hooks/useTasks';
import type { Task, Subtask } from '@veritas-kanban/shared';
import { FileCode, AlertCircle, Plus, Minus } from 'lucide-react';
import { nanoid } from 'nanoid';
import { api } from '@/lib/api';
import {
interpolateVariables,
extractCustomVariables,
type VariableContext
} from '@/lib/template-variables';
import { getCategoryIcon } from '@/lib/template-categories';
interface ApplyTemplateDialogProps {
task: Task;
open: boolean;
onOpenChange: (open: boolean) => void;
onApplied?: () => void;
}
interface MergedField {
field: string;
label: string;
before: string | undefined;
after: string;
willChange: boolean;
}
interface MergePreview {
fields: MergedField[];
subtasksAdded: number;
existingSubtasks: number;
}
export function ApplyTemplateDialog({ task, open, onOpenChange, onApplied }: ApplyTemplateDialogProps) {
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const [categoryFilter, setCategoryFilter] = useState<string>('all');
const [customVars, setCustomVars] = useState<Record<string, string>>({});
const [requiredCustomVars, setRequiredCustomVars] = useState<string[]>([]);
const [forceOverwrite, setForceOverwrite] = useState(false);
const { data: templates } = useTemplates();
const updateTask = useUpdateTask();
// Filter templates by selected category
const filteredTemplates = useMemo(() => {
if (!templates) return [];
if (categoryFilter === 'all') return templates;
return templates.filter(t => (t.category || 'custom') === categoryFilter);
}, [templates, categoryFilter]);
// Get the selected template
const template = useMemo(() => {
if (!selectedTemplate || !templates) return null;
return templates.find(t => t.id === selectedTemplate) || null;
}, [selectedTemplate, templates]);
// Calculate merge preview
const mergePreview = useMemo((): MergePreview | null => {
if (!template) return null;
// Build variable context
const context: VariableContext = {
project: task.project,
author: 'Brad', // TODO: Get from user config
customVars,
};
const fields: MergedField[] = [];
// Title
if (template.taskDefaults.descriptionTemplate) {
const interpolatedTitle = task.title;
const willChange = forceOverwrite || !task.title;
fields.push({
field: 'title',
label: 'Title',
before: task.title,
after: interpolatedTitle,
willChange,
});
}
// Description
if (template.taskDefaults.descriptionTemplate) {
const interpolatedDescription = interpolateVariables(
template.taskDefaults.descriptionTemplate,
context
);
const willChange = forceOverwrite || !task.description;
fields.push({
field: 'description',
label: 'Description',
before: task.description,
after: interpolatedDescription,
willChange,
});
}
// Type
if (template.taskDefaults.type) {
const willChange = forceOverwrite || !task.type;
fields.push({
field: 'type',
label: 'Type',
before: task.type,
after: template.taskDefaults.type,
willChange,
});
}
// Priority
if (template.taskDefaults.priority) {
const willChange = forceOverwrite || !task.priority;
fields.push({
field: 'priority',
label: 'Priority',
before: task.priority,
after: template.taskDefaults.priority,
willChange,
});
}
// Project
if (template.taskDefaults.project) {
const willChange = forceOverwrite || !task.project;
fields.push({
field: 'project',
label: 'Project',
before: task.project,
after: template.taskDefaults.project,
willChange,
});
}
// Tags
if (template.taskDefaults.tags && template.taskDefaults.tags.length > 0) {
const willChange = forceOverwrite || !task.tags || task.tags.length === 0;
fields.push({
field: 'tags',
label: 'Tags',
before: task.tags?.join(', '),
after: template.taskDefaults.tags.join(', '),
willChange,
});
}
// Subtasks
const subtasksAdded = template.subtaskTemplates?.length || 0;
const existingSubtasks = task.subtasks?.length || 0;
return {
fields: fields.filter(f => f.willChange),
subtasksAdded,
existingSubtasks,
};
}, [template, task, customVars, forceOverwrite]);
// Handle template selection
const handleTemplateSelect = (templateId: string) => {
setSelectedTemplate(templateId);
const selected = templates?.find(t => t.id === templateId);
if (!selected) return;
// Extract custom variables from description template and subtasks
const allTemplateText = [
selected.taskDefaults.descriptionTemplate || '',
...(selected.subtaskTemplates?.map(st => st.title) || [])
].join(' ');
const customVarNames = extractCustomVariables(allTemplateText);
setRequiredCustomVars(customVarNames);
// Initialize custom vars
const initialCustomVars: Record<string, string> = {};
customVarNames.forEach(name => {
initialCustomVars[name] = '';
});
setCustomVars(initialCustomVars);
};
// Apply the template
const handleApply = async () => {
if (!template) return;
// Build variable context
const context: VariableContext = {
project: task.project,
author: 'Brad', // TODO: Get from user config
customVars,
};
// Build update input based on merge strategy
const updates: Record<string, unknown> = {};
// Description
if (template.taskDefaults.descriptionTemplate) {
const interpolated = interpolateVariables(
template.taskDefaults.descriptionTemplate,
context
);
if (forceOverwrite || !task.description) {
updates.description = interpolated;
}
}
// Type
if (template.taskDefaults.type && (forceOverwrite || !task.type)) {
updates.type = template.taskDefaults.type;
}
// Priority
if (template.taskDefaults.priority && (forceOverwrite || !task.priority)) {
updates.priority = template.taskDefaults.priority;
}
// Project
if (template.taskDefaults.project && (forceOverwrite || !task.project)) {
updates.project = template.taskDefaults.project;
}
// Tags
if (template.taskDefaults.tags && template.taskDefaults.tags.length > 0) {
if (forceOverwrite || !task.tags || task.tags.length === 0) {
updates.tags = template.taskDefaults.tags;
}
}
// Subtasks - APPEND to existing
if (template.subtaskTemplates && template.subtaskTemplates.length > 0) {
const now = new Date().toISOString();
const newSubtasks: Subtask[] = template.subtaskTemplates
.sort((a, b) => a.order - b.order)
.map(st => ({
id: nanoid(),
title: interpolateVariables(st.title, context),
completed: false,
created: now,
}));
// Append to existing subtasks
const existingSubtasks = task.subtasks || [];
updates.subtasks = [...existingSubtasks, ...newSubtasks];
}
// Apply the updates
await updateTask.mutateAsync({
id: task.id,
input: updates,
});
// Track which fields were changed for activity logging
const changedFields = Object.keys(updates);
// Log activity
try {
await api.tasks.applyTemplate(
task.id,
template.id,
template.name,
changedFields
);
} catch (error) {
console.error('Failed to log template application:', error);
// Don't fail the whole operation if logging fails
}
// Close dialog and notify parent
onOpenChange(false);
onApplied?.();
// Reset state
setSelectedTemplate(null);
setCustomVars({});
setRequiredCustomVars([]);
setForceOverwrite(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileCode className="h-5 w-5" />
Apply Template to Task
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* Template selector */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Label className="text-sm">Select Template</Label>
</div>
<Tabs value={categoryFilter} onValueChange={setCategoryFilter}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="all" className="text-xs">All</TabsTrigger>
<TabsTrigger value="bug" className="text-xs">🐛</TabsTrigger>
<TabsTrigger value="feature" className="text-xs">✨</TabsTrigger>
<TabsTrigger value="sprint" className="text-xs">🔄</TabsTrigger>
</TabsList>
</Tabs>
<Select
value={selectedTemplate || 'none'}
onValueChange={(value) => {
if (value === 'none') {
setSelectedTemplate(null);
} else {
handleTemplateSelect(value);
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Choose a template..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No template selected</SelectItem>
{filteredTemplates
.filter(t => !t.blueprint) // Exclude blueprint templates
.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.category && `${getCategoryIcon(template.category)} `}
{template.name}
{template.description && (
<span className="text-muted-foreground ml-2">
— {template.description}
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Custom variable inputs */}
{requiredCustomVars.length > 0 && (
<div className="grid gap-3 border rounded-md p-3 bg-muted/30">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-blue-500" />
<Label className="text-sm font-medium">Template Variables</Label>
</div>
{requiredCustomVars.map((varName) => (
<div key={varName} className="grid gap-1.5">
<Label htmlFor={`var-${varName}`} className="text-xs">
{varName}
</Label>
<Input
id={`var-${varName}`}
value={customVars[varName] || ''}
onChange={(e) => setCustomVars(prev => ({ ...prev, [varName]: e.target.value }))}
placeholder={`Enter ${varName}...`}
className="h-8"
/>
</div>
))}
</div>
)}
{/* Force overwrite toggle */}
{template && (
<div className="flex items-center justify-between border rounded-md p-3 bg-muted/30">
<div className="space-y-0.5">
<Label className="text-sm font-medium">Force Overwrite</Label>
<p className="text-xs text-muted-foreground">
Replace existing values with template values
</p>
</div>
<Switch
checked={forceOverwrite}
onCheckedChange={setForceOverwrite}
/>
</div>
)}
{/* Merge preview */}
{mergePreview && mergePreview.fields.length > 0 && (
<div className="border rounded-md p-3 bg-muted/30 space-y-3">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-blue-500" />
<Label className="text-sm font-medium">Changes Preview</Label>
</div>
{mergePreview.fields.map((field) => (
<div key={field.field} className="text-sm border-l-2 border-primary/50 pl-3 py-1">
<div className="font-medium text-xs text-muted-foreground uppercase">
{field.label}
</div>
<div className="flex items-start gap-2 mt-1">
<Minus className="h-3 w-3 text-red-500 mt-0.5 flex-shrink-0" />
<span className="text-red-500/80 line-through flex-1">
{field.before || '(empty)'}
</span>
</div>
<div className="flex items-start gap-2">
<Plus className="h-3 w-3 text-green-500 mt-0.5 flex-shrink-0" />
<span className="text-green-500/80 flex-1">
{field.after}
</span>
</div>
</div>
))}
{mergePreview.subtasksAdded > 0 && (
<div className="text-sm border-l-2 border-primary/50 pl-3 py-1">
<div className="font-medium text-xs text-muted-foreground uppercase">
Subtasks
</div>
<div className="flex items-center gap-2 mt-1">
<Plus className="h-3 w-3 text-blue-500" />
<span className="text-sm">
Will add {mergePreview.subtasksAdded} subtasks to existing {mergePreview.existingSubtasks}
</span>
</div>
</div>
)}
</div>
)}
{!template && (
<div className="text-center text-sm text-muted-foreground py-8">
Select a template to see what will change
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="button"
onClick={handleApply}
disabled={!template || updateTask.isPending}
>
{updateTask.isPending ? 'Applying...' : 'Apply Template'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -34,7 +34,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { useUpdateTask, useDeleteTask } from '@/hooks/useTasks';
import { Trash2, Code, Search, FileText, Zap, Calendar, Clock, GitBranch, Bot, FileDiff, ClipboardCheck, Monitor } from 'lucide-react';
import { Trash2, Code, Search, FileText, Zap, Calendar, Clock, GitBranch, Bot, FileDiff, ClipboardCheck, Monitor, FileCode } from 'lucide-react';
import type { Task, TaskType, TaskStatus, TaskPriority, ReviewComment, ReviewState } from '@veritas-kanban/shared';
import { GitSection } from './GitSection';
import { AgentPanel } from './AgentPanel';
@ -44,6 +44,7 @@ import { SubtasksSection } from './SubtasksSection';
import { DependenciesSection } from './DependenciesSection';
import { PreviewPanel } from './PreviewPanel';
import { TimeTrackingSection } from './TimeTrackingSection';
import { ApplyTemplateDialog } from './ApplyTemplateDialog';
interface TaskDetailPanelProps {
task: Task | null;
@ -125,6 +126,7 @@ export function TaskDetailPanel({ task, open, onOpenChange }: TaskDetailPanelPro
const { localTask, updateField, isDirty } = useDebouncedSave(task, updateTask);
const [activeTab, setActiveTab] = useState('details');
const [previewOpen, setPreviewOpen] = useState(false);
const [applyTemplateOpen, setApplyTemplateOpen] = useState(false);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@ -203,6 +205,15 @@ export function TaskDetailPanel({ task, open, onOpenChange }: TaskDetailPanelPro
</>
)}
</TabsList>
<Button
variant="outline"
size="sm"
onClick={() => setApplyTemplateOpen(true)}
className="flex items-center gap-1"
>
<FileCode className="h-3 w-3" />
Template
</Button>
{isCodeTask && localTask.git?.repo && (
<Button
variant="outline"
@ -432,6 +443,15 @@ export function TaskDetailPanel({ task, open, onOpenChange }: TaskDetailPanelPro
onOpenChange={setPreviewOpen}
/>
)}
{/* Apply Template Dialog */}
{localTask && (
<ApplyTemplateDialog
task={localTask}
open={applyTemplateOpen}
onOpenChange={setApplyTemplateOpen}
/>
)}
</Sheet>
);
}

View file

@ -134,6 +134,15 @@ export const api = {
const response = await fetch(`${API_BASE}/tasks/${taskId}/blocking-status`);
return handleResponse(response);
},
applyTemplate: async (taskId: string, templateId: string, templateName: string, fieldsChanged: string[]): Promise<void> => {
const response = await fetch(`${API_BASE}/tasks/${taskId}/apply-template`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ templateId, templateName, fieldsChanged }),
});
return handleResponse<void>(response);
},
},
config: {