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
95 changes: 92 additions & 3 deletions client/src/components/TaskDetailModal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { TdIssueWithChildren } from '../lib/types'
import {
getLinkedSessions,
getTaskDetailLayoutCounts,
hasSchedulingDetails,
parseAcceptanceCriteria,
} from '../lib/taskDetailLayout'

Expand All @@ -14,7 +15,7 @@ function makeIssue(overrides: Partial<TdIssueWithChildren> = {}): TdIssueWithChi
status: 'open',
type: 'task',
priority: 'P1',
points: 1,
points: 0,
labels: '',
parent_id: '',
acceptance: '',
Expand All @@ -27,9 +28,14 @@ function makeIssue(overrides: Partial<TdIssueWithChildren> = {}): TdIssueWithChi
minor: 0,
created_branch: '',
creator_session: '',
sprint: '',
defer_until: null,
due_date: null,
defer_count: 0,
children: [],
handoffs: [],
files: [],
comments: [],
...overrides,
}
}
Expand Down Expand Up @@ -60,6 +66,7 @@ describe('TaskDetailModal layout helpers', () => {
description: 'Some description',
acceptance: '- one\n- two',
labels: 'webui, ux',
points: 3,
implementer_session: 'ses_impl',
reviewer_session: 'ses_rev',
children: [
Expand All @@ -77,6 +84,15 @@ describe('TaskDetailModal layout helpers', () => {
timestamp: '2026-02-20 08:30:10 +0000 UTC',
},
],
comments: [
{
id: 'comment-1',
issue_id: 'td-1',
session_id: 'ses_impl',
text: 'Looks good.',
created_at: '2026-02-20 08:30:10 +0000 UTC',
},
],
files: [
{
id: 'file-1',
Expand All @@ -89,8 +105,8 @@ describe('TaskDetailModal layout helpers', () => {

expect(getTaskDetailLayoutCounts(issue)).toEqual({
overview: 4,
activity: 2,
details: 2,
activity: 3,
details: 3,
})
})

Expand All @@ -102,4 +118,77 @@ describe('TaskDetailModal layout helpers', () => {
details: 1,
})
})

it('adds scheduling section count only when scheduling values exist', () => {
const withScheduling = makeIssue({
due_date: '2026-02-25',
})
const withoutScheduling = makeIssue()

expect(hasSchedulingDetails(withScheduling)).toBe(true)
expect(hasSchedulingDetails(withoutScheduling)).toBe(false)

expect(getTaskDetailLayoutCounts(withScheduling)).toEqual({
overview: 0,
activity: 0,
details: 2,
})
})

it('treats defer_count as scheduling detail', () => {
const issue = makeIssue({
defer_count: 2,
})

expect(hasSchedulingDetails(issue)).toBe(true)
expect(getTaskDetailLayoutCounts(issue)).toEqual({
overview: 0,
activity: 0,
details: 2,
})
})

it('keeps acceptance criteria as a visible overview section without description', () => {
const issue = makeIssue({
acceptance: '- must pass',
})

expect(getTaskDetailLayoutCounts(issue)).toEqual({
overview: 1,
activity: 0,
details: 1,
})
})

it('counts comments section as activity when comments exist', () => {
const issue = makeIssue({
comments: [
{
id: 'comment-1',
issue_id: 'td-1',
session_id: 'ses_reviewer',
text: 'Needs one more test.',
created_at: '2026-02-20 08:30:10 +0000 UTC',
},
],
})

expect(getTaskDetailLayoutCounts(issue)).toEqual({
overview: 0,
activity: 1,
details: 1,
})
})

it('does not throw when comments payload is missing', () => {
const legacy = makeIssue()
delete (legacy as { comments?: unknown }).comments

expect(() => getTaskDetailLayoutCounts(legacy)).not.toThrow()
expect(getTaskDetailLayoutCounts(legacy)).toEqual({
overview: 0,
activity: 0,
details: 1,
})
})
})
149 changes: 120 additions & 29 deletions client/src/components/TaskDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/lib/store'
import {
formatTdAbsolute,
formatTdDateValue,
formatTdRelative,
parseTdTimestamp,
} from '@/lib/tdTimestamp'
import type { TdIssueWithChildren, TdHandoffParsed, TdIssue } from '@/lib/types'
import {
getLinkedSessions,
getTaskDetailLayoutCounts,
hasSchedulingDetails,
parseAcceptanceCriteria,
} from '@/lib/taskDetailLayout'
import type { LucideIcon } from 'lucide-react'
Expand All @@ -32,17 +39,15 @@ import {

type TaskDetailTab = 'overview' | 'activity' | 'details'

/**
* Parse Go-formatted timestamps like "2026-02-19 08:30:10.451538 +0100 CET m=+0.117390418"
* into a JavaScript Date. Strips the timezone abbreviation and monotonic clock suffix.
*/
function parseGoDate(s: string): Date | null {
if (!s) return null
// Extract: YYYY-MM-DD HH:MM:SS +ZZZZ (ignore fractional seconds, tz name, monotonic clock)
const match = s.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})(?:\.\d+)?\s+([+-]\d{2})(\d{2})/)
if (!match) return null
const d = new Date(`${match[1]}T${match[2]}${match[3]}:${match[4]}`)
return isNaN(d.getTime()) ? null : d
function RelativeTime({ timestamp, nowMs }: { timestamp?: string | null; nowMs: number }) {
const date = parseTdTimestamp(timestamp ?? '')
if (!date) return <span>—</span>

return (
<time dateTime={date.toISOString()} title={formatTdAbsolute(date)}>
{formatTdRelative(date, nowMs)}
</time>
)
}

const statusConfig: Record<string, { icon: typeof Circle; color: string; bg: string; label: string }> = {
Expand All @@ -60,12 +65,12 @@ const priorityConfig: Record<string, { color: string; label: string }> = {
P3: { color: 'text-muted-foreground/50 bg-muted/30', label: 'Low' },
}

function HandoffSection({ handoff }: { handoff: TdHandoffParsed }) {
function HandoffSection({ handoff, nowMs }: { handoff: TdHandoffParsed; nowMs: number }) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<Clock className="h-3 w-3" />
<span>{parseGoDate(handoff.timestamp)?.toLocaleString() ?? '—'}</span>
<RelativeTime timestamp={handoff.timestamp} nowMs={nowMs} />
{handoff.sessionId && (
<span className="font-mono">{handoff.sessionId}</span>
)}
Expand Down Expand Up @@ -183,6 +188,7 @@ export function TaskDetailModal({ issueId, onClose, onNavigate, onStartWorking,
const [showCommentInput, setShowCommentInput] = useState(false)
const [commentText, setCommentText] = useState('')
const [activeTab, setActiveTab] = useState<TaskDetailTab>('overview')
const [nowMs, setNowMs] = useState(() => Date.now())

// Animate in
useEffect(() => {
Expand All @@ -205,6 +211,11 @@ export function TaskDetailModal({ issueId, onClose, onNavigate, onStartWorking,
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleClose])

useEffect(() => {
const timer = window.setInterval(() => setNowMs(Date.now()), 60_000)
return () => window.clearInterval(timer)
}, [])

// Fetch issue details
useEffect(() => {
setLoading(true)
Expand All @@ -224,6 +235,15 @@ export function TaskDetailModal({ issueId, onClose, onNavigate, onStartWorking,
const labels = issue?.labels ? issue.labels.split(',').map(label => label.trim()).filter(Boolean) : []
const linkedSessions = issue ? getLinkedSessions(issue) : []
const acceptanceCriteria = issue ? parseAcceptanceCriteria(issue.acceptance) : []
const hasScheduling = issue ? hasSchedulingDetails(issue) : false
const children = Array.isArray(issue?.children) ? issue.children : []
const handoffs = Array.isArray(issue?.handoffs) ? issue.handoffs : []
const comments = issue && Array.isArray(issue.comments) ? issue.comments : []
const files = Array.isArray(issue?.files) ? issue.files : []
const dueDate = typeof issue?.due_date === 'string' ? issue.due_date.trim() : ''
const deferUntil = typeof issue?.defer_until === 'string' ? issue.defer_until.trim() : ''
const sprint = typeof issue?.sprint === 'string' ? issue.sprint.trim() : ''
const createdBranch = issue?.created_branch ? issue.created_branch.trim() : ''
const layoutCounts = issue
? getTaskDetailLayoutCounts(issue)
: { overview: 0, activity: 0, details: 0 }
Expand Down Expand Up @@ -508,10 +528,10 @@ export function TaskDetailModal({ issueId, onClose, onNavigate, onStartWorking,
</CollapsibleSection>
)}

{issue.children.length > 0 && (
<CollapsibleSection title="Subtasks" icon={Layers} count={issue.children.length} defaultOpen>
{children.length > 0 && (
<CollapsibleSection title="Subtasks" icon={Layers} count={children.length} defaultOpen>
<div className="space-y-1">
{issue.children.map((child: TdIssue) => {
{children.map((child: TdIssue) => {
const childStatus = statusConfig[child.status]
const ChildIcon = childStatus?.icon || Circle
return (
Expand Down Expand Up @@ -574,36 +594,52 @@ export function TaskDetailModal({ issueId, onClose, onNavigate, onStartWorking,
</CollapsibleSection>
)}

{issue.handoffs.length > 0 && (
<CollapsibleSection title="Handoffs" icon={Clock} count={issue.handoffs.length} defaultOpen>
{handoffs.length > 0 && (
<CollapsibleSection title="Handoffs" icon={Clock} count={handoffs.length} defaultOpen>
<div className="space-y-3">
{issue.handoffs.map((handoff, i) => (
{handoffs.map((handoff, i) => (
<div key={handoff.id} className={cn(
'rounded border border-border p-3',
i === 0 && 'border-primary/30'
)}>
{i === 0 && (
<span className="text-[9px] uppercase tracking-wider text-primary mb-1.5 block">Latest</span>
)}
<HandoffSection handoff={handoff} />
<HandoffSection handoff={handoff} nowMs={nowMs} />
</div>
))}
</div>
</CollapsibleSection>
)}

{linkedSessions.length === 0 && issue.handoffs.length === 0 && (
{comments.length > 0 && (
<CollapsibleSection title="Comments" icon={MessageSquare} count={comments.length} defaultOpen>
<div className="space-y-2">
{comments.map(comment => (
<article key={comment.id} className="rounded border border-border p-2.5">
<p className="text-xs text-muted-foreground whitespace-pre-wrap leading-relaxed">{comment.text}</p>
<div className="mt-1.5 flex items-center gap-2 text-[10px] text-muted-foreground">
<RelativeTime timestamp={comment.created_at} nowMs={nowMs} />
<span className="font-mono">{comment.session_id}</span>
</div>
</article>
))}
</div>
</CollapsibleSection>
)}

{linkedSessions.length === 0 && handoffs.length === 0 && comments.length === 0 && (
<div className="rounded border border-dashed border-border p-3 text-xs text-muted-foreground">
No activity yet.
</div>
)}
</TabsContent>

<TabsContent value="details" className="mt-2 space-y-2">
{issue.files.length > 0 && (
<CollapsibleSection title="Files" icon={FileText} count={issue.files.length} defaultOpen>
{files.length > 0 && (
<CollapsibleSection title="Files" icon={FileText} count={files.length} defaultOpen>
<div className="space-y-1">
{issue.files.map(file => (
{files.map(file => (
<div key={file.id} className="flex items-center gap-2 text-xs">
<FileText className="h-3 w-3 text-muted-foreground shrink-0" />
<span className="font-mono text-muted-foreground truncate">{file.file_path}</span>
Expand All @@ -616,18 +652,73 @@ export function TaskDetailModal({ issueId, onClose, onNavigate, onStartWorking,
</CollapsibleSection>
)}

{hasScheduling && (
<CollapsibleSection title="Scheduling" icon={Clock} defaultOpen>
<div className="grid grid-cols-2 gap-y-1 text-xs">
{dueDate && (
<>
<span className="text-muted-foreground">Due</span>
<span>{formatTdDateValue(dueDate)}</span>
</>
)}
{deferUntil && (
<>
<span className="text-muted-foreground">Deferred Until</span>
<span>{formatTdDateValue(deferUntil)}</span>
</>
)}
{issue.points > 0 && (
<>
<span className="text-muted-foreground">Points</span>
<span>{issue.points}</span>
</>
)}
{sprint && (
<>
<span className="text-muted-foreground">Sprint</span>
<span>{sprint}</span>
</>
)}
{issue.minor === 1 && (
<>
<span className="text-muted-foreground">Minor</span>
<span>Yes</span>
</>
)}
{issue.defer_count > 0 && (
<>
<span className="text-muted-foreground">Defer Count</span>
<span>{issue.defer_count}</span>
</>
)}
{createdBranch && (
<>
<span className="text-muted-foreground">Branch</span>
<span className="font-mono">{createdBranch}</span>
</>
)}
</div>
</CollapsibleSection>
)}

<CollapsibleSection title="Metadata" icon={Clock} defaultOpen>
<div className="grid grid-cols-2 gap-y-1 text-xs">
<span className="text-muted-foreground">Created</span>
<span>{parseGoDate(issue.created_at)?.toLocaleString() ?? '—'}</span>
<RelativeTime timestamp={issue.created_at} nowMs={nowMs} />
<span className="text-muted-foreground">Updated</span>
<span>{parseGoDate(issue.updated_at)?.toLocaleString() ?? '—'}</span>
{issue.created_branch && (
<RelativeTime timestamp={issue.updated_at} nowMs={nowMs} />
{issue.closed_at && (
<>
<span className="text-muted-foreground">Branch</span>
<span className="font-mono">{issue.created_branch}</span>
<span className="text-muted-foreground">Closed</span>
<RelativeTime timestamp={issue.closed_at} nowMs={nowMs} />
</>
)}
<span className="text-muted-foreground">Parent ID</span>
<span className="font-mono">{issue.parent_id || '—'}</span>
<span className="text-muted-foreground">Creator Session</span>
<span className="font-mono">{issue.creator_session || '—'}</span>
<span className="text-muted-foreground">Defer Count</span>
<span>{issue.defer_count}</span>
Comment on lines +720 to +721

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The Defer Count is displayed in the "Metadata" section, but it's also shown in the "Scheduling" section when its value is greater than zero. This creates redundant information in the UI. To avoid this duplication, I suggest removing it from the "Metadata" section, as "Scheduling" is a more appropriate place for this information.

</div>
</CollapsibleSection>
</TabsContent>
Expand Down
Loading