feat(kanban): open tickets in a centered two-column modal; dependency chips resolve titles

- Desktop drawer -> Linear-style modal (smaller than Settings): main column
  holds diagnostics, description, result/summary, dependencies, comments,
  activity, runs, and the worker log tail; a right property sidebar holds the
  inline editors (assignee, model override) plus priority/tenant/workspace/
  created rows, estimate, and attachments. Backdrop click or Esc closes.
- GET /tasks/:id gains 'link_tasks' ({id,title,status} per linked task) so
  Blocks/Blocked By chips render titles instead of raw ids; older backends
  fall back to short ids. Additive; 'links' shape unchanged.
- New backend tests (test_kanban_link_tasks.py) + drawer tests for title
  chips and the id fallback.
This commit is contained in:
Ben Barclay
2026-09-22 20:13:17 +10:00
committed by Teknium
parent 8dfae717c1
commit 2476c82837
5 changed files with 464 additions and 283 deletions

View File

@@ -127,3 +127,46 @@ describe('task attachment compatibility', () => {
expect(screen.queryByText(en.noAttachments)).toBeNull()
})
})
describe('dependency chips resolve titles', () => {
const linkedDetail = {
...legacyDetail,
attachments: [] as [],
links: { parents: ['t_parent'], children: ['t_child'] },
link_tasks: [
{ id: 't_parent', title: 'Parent title', status: 'todo' },
{ id: 't_child', title: 'Child title', status: 'running' }
]
}
it('renders linked task titles, not raw ids, and opens on click', async () => {
detail = linkedDetail
const onOpen = vi.fn()
render(
<QueryClientProvider client={client}>
<TaskDrawer columns={['todo', 'ready', 'done']} id="t_example" onClose={vi.fn()} onOpen={onOpen} />
</QueryClientProvider>
)
expect(await screen.findByRole('heading', { name: legacyDetail.task.title })).toBeTruthy()
expect(screen.getByText('Parent title')).toBeTruthy()
expect(screen.getByText('Child title')).toBeTruthy()
expect(screen.queryByText('parent')).toBeNull()
fireEvent.click(screen.getByText('Parent title'))
expect(onOpen).toHaveBeenCalledWith('t_parent')
})
it('falls back to short ids when the backend omits link_tasks', async () => {
const { link_tasks: _omit, ...withoutTitles } = linkedDetail
detail = withoutTitles
render(
<QueryClientProvider client={client}>
<TaskDrawer columns={['todo', 'ready', 'done']} id="t_example" onClose={vi.fn()} onOpen={vi.fn()} />
</QueryClientProvider>
)
expect(await screen.findByText('parent')).toBeTruthy()
expect(screen.getByText('child')).toBeTruthy()
})
})

View File

@@ -1,8 +1,8 @@
/**
* Task drawer — the desktop port of the dashboard's task detail, flat-styled:
* status menu + meta table, DIAGNOSTICS (the "why is this stuck" panel, with
* reassign recovery), description (editable), result/summary, dependencies,
* comments (+composer), activity, run history, and the worker log tail.
* Task modal — the desktop port of the dashboard's task detail, Linear-style:
* a centered two-column dialog (main: diagnostics, description, result,
* dependencies, comments, activity, runs, log tail; right sidebar: property
* rows with the inline editors), instead of the old cramped right drawer.
*/
import {
@@ -173,12 +173,14 @@ function eventText(event: KanbanEvent, k: KanbanText): { detail?: string; label:
}
}
// Sidebar property row: muted label above the value (Linear's property list).
// The value slot holds the same inline editors the old meta grid had.
function MetaRow({ children, label }: { children: ReactNode; label: string }) {
return (
<>
<span className="text-(--ui-text-quaternary)">{label}</span>
<span className="min-w-0 truncate text-(--ui-text-secondary)">{children}</span>
</>
<div className="flex flex-col gap-0.5">
<span className="text-[0.65rem] tracking-wide text-(--ui-text-quaternary) uppercase">{label}</span>
<div className="min-w-0 text-[0.75rem] text-(--ui-text-secondary)">{children}</div>
</div>
)
}
@@ -665,6 +667,10 @@ export function TaskDrawer({
const errorMessage = error ? errText(error) : null
// Linked tasks resolved to titles by the backend (`link_tasks`); absent on
// older backends, where the chips fall back to short ids.
const linkTitles = new Map((detail?.link_tasks ?? []).map(linked => [linked.id, linked.title]))
const move = (status: string) => {
if (!task || status === task.status) {
return
@@ -680,286 +686,314 @@ export function TaskDrawer({
}
return (
<div className="absolute inset-y-0 right-0 z-20 flex w-[26rem] flex-col border-l border-(--ui-stroke-tertiary) bg-(--ui-bg-elevated) duration-150 ease-out animate-in fade-in slide-in-from-right-4">
<header className="flex flex-col gap-2 px-4 pt-3.5 pb-3">
<div className="flex items-center gap-2">
{task ? (
<StatusMenu columns={columns} onMove={move} status={task.status} />
) : (
<span className="font-mono text-sm text-(--ui-text-tertiary)">{shortId(id)}</span>
)}
{task && (
<span className="font-mono text-[0.625rem] text-(--ui-text-quaternary)" data-selectable-text="true">
{shortId(task.id)}
</span>
)}
<div className="ml-auto flex items-center gap-0.5">
<div
aria-label={task?.title || shortId(id)}
aria-modal="true"
className="fixed inset-0 z-(--z-modal-backdrop) flex items-center justify-center bg-black/45 p-6 duration-150 animate-in fade-in-0"
onMouseDown={event => event.target === event.currentTarget && onClose()}
role="dialog"
>
<div className="flex h-[min(84vh,54rem)] w-[min(62rem,94vw)] flex-col overflow-hidden rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-bg-elevated) shadow-nous duration-150 animate-in fade-in-0 zoom-in-95">
<header className="flex flex-col gap-2 px-5 pt-4 pb-3">
<div className="flex items-center gap-2">
{task ? (
<StatusMenu columns={columns} onMove={move} status={task.status} />
) : (
<span className="font-mono text-sm text-(--ui-text-tertiary)">{shortId(id)}</span>
)}
{task && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label={k.taskActions}
className="grid size-6 place-items-center rounded text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground"
type="button"
>
<Codicon name="ellipsis" size="0.9rem" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => {
void navigator.clipboard.writeText(task.id)
host.notify({ kind: 'info', message: k.copiedId(task.id) })
}}
>
<Codicon name="copy" size="0.85rem" />
{k.copyTaskId}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void navigator.clipboard.writeText(task.title || task.id)
host.notify({ kind: 'info', message: k.copiedTitle })
}}
>
<Codicon name="copy" size="0.85rem" />
{k.copyTitle}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={mutate(() => patchTask(task.id, { status: 'archived' }), onClose)}>
<Codicon name="archive" size="0.85rem" />
{k.archive}
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onSelect={mutate(() => deleteTask(task.id), onClose)}>
<Codicon name="trash" size="0.85rem" />
{k.delete}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<span className="font-mono text-[0.625rem] text-(--ui-text-quaternary)" data-selectable-text="true">
{shortId(task.id)}
</span>
)}
<button
aria-label={k.close}
className="grid size-6 place-items-center rounded text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground"
onClick={onClose}
type="button"
>
<Codicon name="close" size="0.9rem" />
</button>
</div>
</div>
{task && (
<h2 className="text-sm leading-snug font-semibold text-foreground" data-selectable-text="true">
{task.title || task.id}
</h2>
)}
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4" data-selectable-text="true">
{errorMessage ? (
<ErrorState title={errorMessage} />
) : !detail || !task ? (
<div className="grid h-32 place-items-center">
<Loader type="lemniscate-bloom" />
</div>
) : (
<div className="flex flex-col gap-4 text-sm">
<div className="grid grid-cols-[6rem_minmax(0,1fr)] gap-x-3 gap-y-1 text-[0.71rem]">
<MetaRow label={k.assignee}>
<AssigneeMenu
current={task.assignee}
onReassign={profile => void mutate(() => reassignTask(task.id, profile))()}
/>
</MetaRow>
{typeof task.priority === 'number' && <MetaRow label={k.metaPriority}>{task.priority}</MetaRow>}
{task.tenant && <MetaRow label={k.metaTenant}>{task.tenant}</MetaRow>}
{task.workspace_path && (
<MetaRow label={k.workspace}>
{task.workspace_kind ? `${task.workspace_kind}: ` : ''}
{task.workspace_path}
</MetaRow>
<div className="ml-auto flex items-center gap-0.5">
{task && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label={k.taskActions}
className="grid size-6 place-items-center rounded text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground"
type="button"
>
<Codicon name="ellipsis" size="0.9rem" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => {
void navigator.clipboard.writeText(task.id)
host.notify({ kind: 'info', message: k.copiedId(task.id) })
}}
>
<Codicon name="copy" size="0.85rem" />
{k.copyTaskId}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void navigator.clipboard.writeText(task.title || task.id)
host.notify({ kind: 'info', message: k.copiedTitle })
}}
>
<Codicon name="copy" size="0.85rem" />
{k.copyTitle}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={mutate(() => patchTask(task.id, { status: 'archived' }), onClose)}>
<Codicon name="archive" size="0.85rem" />
{k.archive}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onSelect={mutate(() => deleteTask(task.id), onClose)}
>
<Codicon name="trash" size="0.85rem" />
{k.delete}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<MetaRow label={k.model}>
<ModelOverrideField
onChange={next => void mutate(() => patchTask(task.id, overridePatch(next)))()}
value={{
effort: task.reasoning_effort ?? '',
model: task.model_override ?? '',
provider: task.provider_override ?? ''
}}
/>
</MetaRow>
{task.created_by && <MetaRow label={k.metaCreatedBy}>{task.created_by}</MetaRow>}
{ago(task.created_at) && <MetaRow label={k.metaCreated}>{ago(task.created_at)}</MetaRow>}
{running && task.worker_pid ? <MetaRow label={k.metaWorkerPid}>{task.worker_pid}</MetaRow> : null}
<button
aria-label={k.close}
className="grid size-6 place-items-center rounded text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground"
onClick={onClose}
type="button"
>
<Codicon name="close" size="0.9rem" />
</button>
</div>
{task.status === 'ready' && !task.assignee && !defaultAssignee && (
<Callout title={k.readyUnassignedTitle} tone={SEVERITY_TONE.warning}>
<p className="text-[0.71rem] leading-relaxed text-(--ui-text-secondary)">{k.readyUnassignedBody}</p>
</Callout>
)}
{task.diagnostics && task.diagnostics.length > 0 && (
<Section label={k.diagnosticsN(task.diagnostics.length)}>
<Diagnostics items={task.diagnostics} onReclaim={() => void mutate(() => reclaimTask(task.id))()} />
</Section>
)}
<DescriptionSection body={task.body} onSave={body => void mutate(() => patchTask(task.id, { body }))()} />
<EstimateSection id={task.id} />
{task.result && (
<Section label={k.result}>
<p className="whitespace-pre-wrap text-[0.8125rem] text-(--ui-text-secondary)">{task.result}</p>
</Section>
)}
{task.latest_summary && !isAdminSummary(task.latest_summary) && (
<Section label={k.latestSummary}>
<p className="whitespace-pre-wrap text-[0.8125rem] text-(--ui-text-secondary)">{task.latest_summary}</p>
</Section>
)}
{(detail.links.parents.length > 0 || detail.links.children.length > 0) && (
<Section label={k.dependencies}>
{(['parents', 'children'] as const).map(side =>
detail.links[side].length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5" key={side}>
<span className="text-[0.6875rem] text-(--ui-text-quaternary)">
{side === 'parents' ? k.blockedBy : k.blocks}
</span>
{detail.links[side].map(linked => (
<button
className="rounded bg-(--ui-bg-quaternary) px-1.5 py-0.5 font-mono text-[0.625rem] text-(--ui-text-secondary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground"
key={linked}
onClick={() => onOpen(linked)}
type="button"
>
{shortId(linked)}
</button>
))}
</div>
) : null
)}
</Section>
)}
<Section
action={
<Tip label={running ? k.commentsHelpRunning : k.commentsHelp}>
<span className="grid size-5 place-items-center rounded text-(--ui-text-quaternary) hover:text-(--ui-text-secondary)">
<Codicon name="question" size="0.8rem" />
</span>
</Tip>
}
label={k.comments(detail.comments.length)}
>
{detail.comments.length > 0 && (
<ul className="flex flex-col gap-2">
{detail.comments.map(comment => (
<li className="text-[0.75rem]" key={comment.id}>
<span className="font-medium text-(--ui-text-secondary)">{comment.author}</span>
<span className="ml-2 text-[0.625rem] text-(--ui-text-quaternary)">
{ago(comment.created_at)}
</span>
<p className="whitespace-pre-wrap text-(--ui-text-tertiary)">{comment.body}</p>
</li>
))}
</ul>
)}
<CommentComposer
onRequeue={body => requeueMut.mutate(body)}
onSubmit={body => commentMut.mutate(body)}
pending={commentMut.isPending || requeueMut.isPending}
running={running}
/>
</Section>
{detail.events.length > 0 && (
<Section label={k.activity(detail.events.length)}>
<ScrollFade deps={detail.events.length} max="7rem">
<ul className="flex flex-col gap-1">
{detail.events.map(event => {
const { detail: extra, label } = eventText(event, k)
return (
<li className="flex items-baseline gap-2 text-[0.6875rem]" key={event.id}>
<span className="shrink-0 text-(--ui-text-secondary)">{label}</span>
{extra && (
<span
className="min-w-0 truncate text-[0.625rem] text-(--ui-text-quaternary)"
title={extra}
>
{extra}
</span>
)}
<span className="ml-auto shrink-0 text-(--ui-text-quaternary)">{ago(event.created_at)}</span>
</li>
)
})}
</ul>
</ScrollFade>
</Section>
)}
{detail.runs.length > 0 && (
<Section label={k.runs(detail.runs.length)}>
<ScrollFade max="11rem">
<ul className="flex flex-col gap-1.5">
{detail.runs.map(run => {
const failed = ['crashed', 'failed', 'timed_out', 'gave_up'].includes(run.outcome ?? run.status)
return (
<li className="flex flex-col gap-0.5 text-[0.71rem]" key={run.id}>
<div className="flex items-center gap-2">
<Badge size="xs" variant={failed ? 'destructive' : 'muted'}>
{run.outcome ?? run.status}
</Badge>
{run.profile && <span className="text-(--ui-text-tertiary)">{run.profile}</span>}
{duration(run.started_at, run.ended_at) && (
<span className="text-(--ui-text-quaternary)">
{duration(run.started_at, run.ended_at)}
</span>
)}
<span className="ml-auto shrink-0 text-(--ui-text-quaternary)">
{ago(run.ended_at ?? run.started_at)}
</span>
</div>
{(run.error || run.summary) && (
<p
className={cn(
'line-clamp-2 whitespace-pre-wrap',
run.error ? 'text-destructive' : 'text-(--ui-text-quaternary)'
)}
>
{run.error ?? run.summary}
</p>
)}
</li>
)
})}
</ul>
</ScrollFade>
</Section>
)}
{log?.exists && log.content && (
<Section label={log.truncated ? k.workerLogTail : k.workerLog}>
<ScrollFade deps={log.content.length} max="12rem">
<LogView className="border-0 px-0">{log.content}</LogView>
</ScrollFade>
</Section>
)}
{Array.isArray(detail.attachments) && (
<AttachmentsSection
attachments={detail.attachments}
onUpload={file => uploadMut.mutate(file)}
pending={uploadMut.isPending}
/>
)}
</div>
)}
{task && (
<h2 className="text-sm leading-snug font-semibold text-foreground" data-selectable-text="true">
{task.title || task.id}
</h2>
)}
</header>
<div className="min-h-0 flex-1" data-selectable-text="true">
{errorMessage ? (
<ErrorState title={errorMessage} />
) : !detail || !task ? (
<div className="grid h-32 place-items-center">
<Loader type="lemniscate-bloom" />
</div>
) : (
<div className="flex h-full min-h-0">
<div className="min-w-0 flex-1 overflow-y-auto px-5 pb-5">
<div className="flex flex-col gap-4 text-sm">
{task.status === 'ready' && !task.assignee && !defaultAssignee && (
<Callout title={k.readyUnassignedTitle} tone={SEVERITY_TONE.warning}>
<p className="text-[0.71rem] leading-relaxed text-(--ui-text-secondary)">
{k.readyUnassignedBody}
</p>
</Callout>
)}
{task.diagnostics && task.diagnostics.length > 0 && (
<Section label={k.diagnosticsN(task.diagnostics.length)}>
<Diagnostics
items={task.diagnostics}
onReclaim={() => void mutate(() => reclaimTask(task.id))()}
/>
</Section>
)}
<DescriptionSection
body={task.body}
onSave={body => void mutate(() => patchTask(task.id, { body }))()}
/>
{task.result && (
<Section label={k.result}>
<p className="whitespace-pre-wrap text-[0.8125rem] text-(--ui-text-secondary)">{task.result}</p>
</Section>
)}
{task.latest_summary && !isAdminSummary(task.latest_summary) && (
<Section label={k.latestSummary}>
<p className="whitespace-pre-wrap text-[0.8125rem] text-(--ui-text-secondary)">
{task.latest_summary}
</p>
</Section>
)}
{(detail.links.parents.length > 0 || detail.links.children.length > 0) && (
<Section label={k.dependencies}>
{(['parents', 'children'] as const).map(side =>
detail.links[side].length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5" key={side}>
<span className="text-[0.6875rem] text-(--ui-text-quaternary)">
{side === 'parents' ? k.blockedBy : k.blocks}
</span>
{detail.links[side].map(linked => (
<button
className="max-w-full truncate rounded bg-(--ui-bg-quaternary) px-1.5 py-0.5 text-[0.6875rem] text-(--ui-text-secondary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground"
key={linked}
onClick={() => onOpen(linked)}
title={`${linkTitles.get(linked) ?? ''} (${shortId(linked)})`}
type="button"
>
{linkTitles.get(linked) || shortId(linked)}
</button>
))}
</div>
) : null
)}
</Section>
)}
<Section
action={
<Tip label={running ? k.commentsHelpRunning : k.commentsHelp}>
<span className="grid size-5 place-items-center rounded text-(--ui-text-quaternary) hover:text-(--ui-text-secondary)">
<Codicon name="question" size="0.8rem" />
</span>
</Tip>
}
label={k.comments(detail.comments.length)}
>
{detail.comments.length > 0 && (
<ul className="flex flex-col gap-2">
{detail.comments.map(comment => (
<li className="text-[0.75rem]" key={comment.id}>
<span className="font-medium text-(--ui-text-secondary)">{comment.author}</span>
<span className="ml-2 text-[0.625rem] text-(--ui-text-quaternary)">
{ago(comment.created_at)}
</span>
<p className="whitespace-pre-wrap text-(--ui-text-tertiary)">{comment.body}</p>
</li>
))}
</ul>
)}
<CommentComposer
onRequeue={body => requeueMut.mutate(body)}
onSubmit={body => commentMut.mutate(body)}
pending={commentMut.isPending || requeueMut.isPending}
running={running}
/>
</Section>
{detail.events.length > 0 && (
<Section label={k.activity(detail.events.length)}>
<ScrollFade deps={detail.events.length} max="7rem">
<ul className="flex flex-col gap-1">
{detail.events.map(event => {
const { detail: extra, label } = eventText(event, k)
return (
<li className="flex items-baseline gap-2 text-[0.6875rem]" key={event.id}>
<span className="shrink-0 text-(--ui-text-secondary)">{label}</span>
{extra && (
<span
className="min-w-0 truncate text-[0.625rem] text-(--ui-text-quaternary)"
title={extra}
>
{extra}
</span>
)}
<span className="ml-auto shrink-0 text-(--ui-text-quaternary)">
{ago(event.created_at)}
</span>
</li>
)
})}
</ul>
</ScrollFade>
</Section>
)}
{detail.runs.length > 0 && (
<Section label={k.runs(detail.runs.length)}>
<ScrollFade max="11rem">
<ul className="flex flex-col gap-1.5">
{detail.runs.map(run => {
const failed = ['crashed', 'failed', 'timed_out', 'gave_up'].includes(
run.outcome ?? run.status
)
return (
<li className="flex flex-col gap-0.5 text-[0.71rem]" key={run.id}>
<div className="flex items-center gap-2">
<Badge size="xs" variant={failed ? 'destructive' : 'muted'}>
{run.outcome ?? run.status}
</Badge>
{run.profile && <span className="text-(--ui-text-tertiary)">{run.profile}</span>}
{duration(run.started_at, run.ended_at) && (
<span className="text-(--ui-text-quaternary)">
{duration(run.started_at, run.ended_at)}
</span>
)}
<span className="ml-auto shrink-0 text-(--ui-text-quaternary)">
{ago(run.ended_at ?? run.started_at)}
</span>
</div>
{(run.error || run.summary) && (
<p
className={cn(
'line-clamp-2 whitespace-pre-wrap',
run.error ? 'text-destructive' : 'text-(--ui-text-quaternary)'
)}
>
{run.error ?? run.summary}
</p>
)}
</li>
)
})}
</ul>
</ScrollFade>
</Section>
)}
{log?.exists && log.content && (
<Section label={log.truncated ? k.workerLogTail : k.workerLog}>
<ScrollFade deps={log.content.length} max="12rem">
<LogView className="border-0 px-0">{log.content}</LogView>
</ScrollFade>
</Section>
)}
</div>
</div>
<aside className="flex w-64 shrink-0 flex-col gap-3 overflow-y-auto border-l border-(--ui-stroke-tertiary) px-4 py-4">
<MetaRow label={k.assignee}>
<AssigneeMenu
current={task.assignee}
onReassign={profile => void mutate(() => reassignTask(task.id, profile))()}
/>
</MetaRow>
{typeof task.priority === 'number' && <MetaRow label={k.metaPriority}>{task.priority}</MetaRow>}
{task.tenant && <MetaRow label={k.metaTenant}>{task.tenant}</MetaRow>}
{task.workspace_path && (
<MetaRow label={k.workspace}>
{task.workspace_kind ? `${task.workspace_kind}: ` : ''}
{task.workspace_path}
</MetaRow>
)}
<MetaRow label={k.model}>
<ModelOverrideField
onChange={next => void mutate(() => patchTask(task.id, overridePatch(next)))()}
value={{
effort: task.reasoning_effort ?? '',
model: task.model_override ?? '',
provider: task.provider_override ?? ''
}}
/>
</MetaRow>
{task.created_by && <MetaRow label={k.metaCreatedBy}>{task.created_by}</MetaRow>}
{ago(task.created_at) && <MetaRow label={k.metaCreated}>{ago(task.created_at)}</MetaRow>}
{running && task.worker_pid ? <MetaRow label={k.metaWorkerPid}>{task.worker_pid}</MetaRow> : null}
<EstimateSection id={task.id} />
{Array.isArray(detail.attachments) && (
<AttachmentsSection
attachments={detail.attachments}
onUpload={file => uploadMut.mutate(file)}
pending={uploadMut.isPending}
/>
)}
</aside>
</div>
)}
</div>
</div>
</div>
)

View File

@@ -91,6 +91,15 @@ export interface KanbanAttachment {
size?: null | number
}
/** GET /tasks/:id `link_tasks` — one resolved row per linked task, so the UI
* renders titles instead of raw ids. Additive: older backends omit it and
* the drawer falls back to shortId chips. */
export interface KanbanLinkTask {
id: string
title: string
status: string
}
/** Fields present only on the detail endpoint (beyond the card's KanbanTask).
* `started_at`/`worker_pid`/`last_heartbeat_at` are inherited — they live on
* KanbanTask now that the board's liveness arc reads them. */
@@ -122,6 +131,7 @@ export interface KanbanTaskDetail {
* section instead of offering uploads the backend would 404 on. */
attachments?: KanbanAttachment[] | null
links: { parents: string[]; children: string[] }
link_tasks?: KanbanLinkTask[] | null
runs: KanbanRun[]
}

View File

@@ -262,6 +262,18 @@ def _links_for(conn: sqlite3.Connection, task_id: str) -> dict[str, list[str]]:
return {"parents": _ids("parent_id", "child_id"), "children": _ids("child_id", "parent_id")}
def _link_tasks(conn: sqlite3.Connection, links: dict[str, list[str]]) -> list[dict]:
"""One {id, title, status} row per linked task, so UIs can render titles
instead of raw ids. Dropped/foreign rows are simply absent — callers fall
back to the id."""
rows = []
for task_id in dict.fromkeys([*links["parents"], *links["children"]]):
task = kanban_db.get_task(conn, task_id)
if task:
rows.append({"id": task.id, "title": task.title, "status": task.status})
return rows
# --- GET /board -------------------------------------------------------------
def get_board(
@@ -368,6 +380,7 @@ def get_task(
"events": [asdict(e) for e in kanban_db.list_events(conn, task_id)],
"attachments": [_attachment_dict(a) for a in kanban_db.list_attachments(conn, task_id)],
"links": links,
"link_tasks": _link_tasks(conn, links),
"child_results": [
{"id": c.id, "title": c.title, "status": c.status, "latest_summary": child_summaries.get(c.id), "result": c.result}
for c in children],

View File

@@ -0,0 +1,81 @@
"""GET /tasks/:id includes `link_tasks` — {id, title, status} rows for every
parent/child — so the desktop drawer renders titles instead of raw ids
(#kanban-modal). Same router-on-bare-FastAPI harness as the attachments tests.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from hermes_cli import kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
def _load_plugin_router():
repo_root = Path(__file__).resolve().parents[2]
plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py"
assert plugin_file.exists(), f"plugin file missing: {plugin_file}"
spec = importlib.util.spec_from_file_location(
"hermes_dashboard_plugin_kanban_linktasks_test", plugin_file,
)
assert spec is not None and spec.loader is not None
mod = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mod
spec.loader.exec_module(mod)
return mod.router
@pytest.fixture
def kanban_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()
return home
@pytest.fixture
def api(kanban_home):
app = FastAPI()
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
return TestClient(app)
def test_link_tasks_resolves_titles_both_sides(kanban_home, api):
conn = kbc.connect()
try:
parent = kb.create_task(conn, title="Parent title")
child = kb.create_task(conn, title="Child title")
kb.link_tasks(conn, parent_id=parent, child_id=child)
finally:
conn.close()
# The child's detail: parents resolve, children stay empty.
detail = api.get(f"/api/plugins/kanban/tasks/{child}").json()
assert detail["links"] == {"parents": [parent], "children": []}
assert [(row["id"], row["title"]) for row in detail["link_tasks"]] == [(parent, "Parent title")]
assert detail["link_tasks"][0]["status"]
# The parent's detail: children resolve.
detail = api.get(f"/api/plugins/kanban/tasks/{parent}").json()
assert detail["links"] == {"parents": [], "children": [child]}
assert [(row["id"], row["title"]) for row in detail["link_tasks"]] == [(child, "Child title")]
def test_link_tasks_empty_when_no_links(kanban_home, api):
conn = kbc.connect()
try:
task_id = kb.create_task(conn, title="Loner")
finally:
conn.close()
detail = api.get(f"/api/plugins/kanban/tasks/{task_id}").json()
assert detail["links"] == {"parents": [], "children": []}
assert detail["link_tasks"] == []