Skip to content

Commit b49a24e

Browse files
dimakisclaude
andcommitted
fix(task-board): fix orphan detection and add session linking
Orphan detection compared task clientIds against SDK sessionIds, so dead tasks were never reclaimed. Now getActiveSessionIds() returns clientIds to match. Running tasks show a clickable session hash that navigates to the chat. Sessions spawned from Telos items or Task Board goals display a tappable source link in the session banner. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6a26dc5 commit b49a24e

14 files changed

Lines changed: 196 additions & 24 deletions

File tree

frontend/src/components/ChatArea.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ export interface ChatAreaProps {
4343
bootContext?: BootContextMeta | null;
4444
/** Session context string for sticky banner */
4545
sessionContext?: string | null;
46+
/** Source link — Telos item or Task Board goal that spawned this session */
47+
sourceLink?: { telosTaskId?: string; goalId?: string } | null;
4648
}
4749

4850
export function ChatArea({
@@ -56,6 +58,7 @@ export function ChatArea({
5658
voice,
5759
bootContext,
5860
sessionContext,
61+
sourceLink,
5962
}: ChatAreaProps) {
6063
const internalScrollRef = useRef<HTMLDivElement>(null);
6164
const scrollRef = externalScrollRef ?? internalScrollRef;
@@ -151,7 +154,11 @@ export function ChatArea({
151154
onTouchStart={handleTouchStart}
152155
onTouchEnd={handleTouchEnd}
153156
>
154-
<SessionBanner bootContext={bootContext} sessionContext={sessionContext} />
157+
<SessionBanner
158+
bootContext={bootContext}
159+
sessionContext={sessionContext}
160+
sourceLink={sourceLink}
161+
/>
155162

156163
{messages.length === 0 && !current && !running && (
157164
<p className="chat-empty">Send a message to start</p>

frontend/src/components/SessionBanner.tsx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { useState, useEffect } from 'react';
2+
import { useNavigate } from 'react-router-dom';
23
import type { BootContextMeta, SectionMeta } from '@mitzo/client';
34

45
interface Props {
56
bootContext?: BootContextMeta | null;
67
sessionContext?: string | null;
8+
sourceLink?: { telosTaskId?: string; goalId?: string } | null;
79
}
810

911
const KIND_LABELS: Record<string, string> = {
@@ -45,7 +47,8 @@ function summaryLine(text: string): string {
4547
return first.length > 80 ? first.slice(0, 77) + '...' : first;
4648
}
4749

48-
export function SessionBanner({ bootContext, sessionContext }: Props) {
50+
export function SessionBanner({ bootContext, sessionContext, sourceLink }: Props) {
51+
const navigate = useNavigate();
4952
const [expanded, setExpanded] = useState(false);
5053
const [showBootDetails, setShowBootDetails] = useState(false);
5154
const [showTrimmed, setShowTrimmed] = useState(false);
@@ -77,7 +80,13 @@ export function SessionBanner({ bootContext, sessionContext }: Props) {
7780
setShowModal(false);
7881
}, [contextKey]);
7982

80-
if (!bootContext && !sessionContext) return null;
83+
if (!bootContext && !sessionContext && !sourceLink) return null;
84+
85+
// Determine source link label and target
86+
const hasGoal = sourceLink?.goalId;
87+
const hasTelos = sourceLink?.telosTaskId && sourceLink.telosTaskId !== sourceLink?.goalId;
88+
const sourceLinkLabel = hasGoal ? 'Task Board' : hasTelos ? 'Telos' : null;
89+
const sourceLinkTarget = hasGoal ? '/tasks' : hasTelos ? '/todos' : null;
8190

8291
const isContexgin = bootContext?.source === 'contexgin';
8392
const dotClass = isContexgin ? 'session-banner-dot--ok' : 'session-banner-dot--warn';
@@ -109,6 +118,25 @@ export function SessionBanner({ bootContext, sessionContext }: Props) {
109118
{sessionContext && (
110119
<span className="session-banner-context-hint">{summaryLine(sessionContext)}</span>
111120
)}
121+
{sourceLinkLabel && sourceLinkTarget && (
122+
<span
123+
className="session-banner-source-link"
124+
role="link"
125+
tabIndex={0}
126+
onClick={(e) => {
127+
e.stopPropagation();
128+
navigate(sourceLinkTarget);
129+
}}
130+
onKeyDown={(e) => {
131+
if (e.key === 'Enter') {
132+
e.stopPropagation();
133+
navigate(sourceLinkTarget);
134+
}
135+
}}
136+
>
137+
{sourceLinkLabel}
138+
</span>
139+
)}
112140
</span>
113141
<span className="session-banner-chevron">{expanded ? '\u25BE' : '\u25B8'}</span>
114142
</button>

frontend/src/components/TaskNode.tsx

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useState } from 'react';
2+
import { useNavigate } from 'react-router-dom';
23
import type { Task, TaskStatus, StageType } from '../types/task';
34
import type { TaskDisplayMeta } from '../hooks/useTaskBoard';
45

@@ -53,12 +54,9 @@ interface TaskNodeProps {
5354

5455
function buildContextLine(task: Task, meta?: TaskDisplayMeta): string | null {
5556
switch (task.status) {
56-
case 'active': {
57-
const parts: string[] = [STATUS_LABELS.active];
58-
if (meta?.sessionHash) parts.push(meta.sessionHash);
59-
if (meta?.elapsedLabel) parts.push(meta.elapsedLabel);
60-
return parts.join(' \u00B7 ');
61-
}
57+
// 'active' handled inline in JSX for clickable session link
58+
case 'active':
59+
return null;
6260
case 'pending_review':
6361
return 'awaiting approval';
6462
case 'blocked':
@@ -74,6 +72,52 @@ function buildContextLine(task: Task, meta?: TaskDisplayMeta): string | null {
7472
}
7573
}
7674

75+
function ActiveContextLine({
76+
meta,
77+
navigate,
78+
}: {
79+
meta?: TaskDisplayMeta;
80+
navigate: (path: string) => void;
81+
}) {
82+
return (
83+
<div className="task-node-context">
84+
<span>{STATUS_LABELS.active}</span>
85+
{meta?.sessionHash && (
86+
<>
87+
{' \u00B7 '}
88+
{meta.sdkSessionId ? (
89+
<span
90+
className="task-node-session-link"
91+
role="link"
92+
tabIndex={0}
93+
onClick={(e) => {
94+
e.stopPropagation();
95+
navigate(`/chat/${meta.sdkSessionId}`);
96+
}}
97+
onKeyDown={(e) => {
98+
if (e.key === 'Enter') {
99+
e.stopPropagation();
100+
navigate(`/chat/${meta.sdkSessionId}`);
101+
}
102+
}}
103+
>
104+
{meta.sessionHash}
105+
</span>
106+
) : (
107+
<span>{meta.sessionHash}</span>
108+
)}
109+
</>
110+
)}
111+
{meta?.elapsedLabel && (
112+
<span>
113+
{' \u00B7 '}
114+
{meta.elapsedLabel}
115+
</span>
116+
)}
117+
</div>
118+
);
119+
}
120+
77121
function contextColorClass(status: TaskStatus): string {
78122
if (status === 'pending_review') return ' task-node-context--review';
79123
if (status === 'blocked') return ' task-node-context--blocked';
@@ -96,6 +140,7 @@ export function TaskNode({
96140
onApprove,
97141
onReject,
98142
}: TaskNodeProps) {
143+
const navigate = useNavigate();
99144
const [expanded, setExpanded] = useState(true);
100145
const hasChildren = task.children.length > 0;
101146
const meta = displayMeta?.get(task.id);
@@ -137,6 +182,9 @@ export function TaskNode({
137182
>
138183
{task.title}
139184
</span>
185+
{task.status === 'active' && !isCompact && (
186+
<ActiveContextLine meta={meta} navigate={navigate} />
187+
)}
140188
{contextLine && (
141189
<div className={`task-node-context${contextColorClass(task.status)}`}>
142190
{contextLine}

frontend/src/components/__tests__/SessionBanner.test.tsx

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
// @vitest-environment jsdom
22
import { describe, it, expect, vi, afterEach } from 'vitest';
33
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
4+
import { MemoryRouter } from 'react-router-dom';
45

56
vi.mock('@mitzo/client', () => ({}));
67

78
import { SessionBanner } from '../SessionBanner';
89

10+
function renderWithRouter(ui: React.ReactElement) {
11+
return render(<MemoryRouter>{ui}</MemoryRouter>);
12+
}
13+
914
afterEach(() => cleanup());
1015

1116
const bootContext = {
@@ -23,23 +28,25 @@ const bootContext = {
2328

2429
describe('SessionBanner', () => {
2530
it('returns null when both props are null', () => {
26-
const { container } = render(<SessionBanner bootContext={null} sessionContext={null} />);
31+
const { container } = renderWithRouter(
32+
<SessionBanner bootContext={null} sessionContext={null} />,
33+
);
2734
expect(container.innerHTML).toBe('');
2835
});
2936

3037
it('renders banner header when bootContext provided', () => {
31-
render(<SessionBanner bootContext={bootContext} />);
38+
renderWithRouter(<SessionBanner bootContext={bootContext} />);
3239
expect(screen.getByText(/5 sources/)).toBeTruthy();
3340
expect(screen.getByText(/4\.2k/)).toBeTruthy();
3441
});
3542

3643
it('renders session context summary when sessionContext provided', () => {
37-
render(<SessionBanner sessionContext="Summary: Build the widget" />);
44+
renderWithRouter(<SessionBanner sessionContext="Summary: Build the widget" />);
3845
expect(screen.getByText(/Build the widget/)).toBeTruthy();
3946
});
4047

4148
it('expands to show full session context on click', () => {
42-
render(<SessionBanner sessionContext="Summary: Build the widget\nStatus: active" />);
49+
renderWithRouter(<SessionBanner sessionContext="Summary: Build the widget\nStatus: active" />);
4350
// Session context text should not be visible initially (collapsed)
4451
expect(screen.queryByText('Session Context')).toBeNull();
4552
// Click the banner header to expand
@@ -48,13 +55,15 @@ describe('SessionBanner', () => {
4855
});
4956

5057
it('renders both boot and session context together', () => {
51-
render(<SessionBanner bootContext={bootContext} sessionContext="Summary: Test task" />);
58+
renderWithRouter(
59+
<SessionBanner bootContext={bootContext} sessionContext="Summary: Test task" />,
60+
);
5261
expect(screen.getByText(/5 sources/)).toBeTruthy();
5362
expect(screen.getByText(/Test task/)).toBeTruthy();
5463
});
5564

5665
it('shows boot context details on nested toggle', () => {
57-
render(<SessionBanner bootContext={bootContext} />);
66+
renderWithRouter(<SessionBanner bootContext={bootContext} />);
5867
// Expand the banner first
5968
fireEvent.click(screen.getByRole('button', { name: /5 sources/ }));
6069
// Now toggle boot context details
@@ -64,7 +73,7 @@ describe('SessionBanner', () => {
6473
});
6574

6675
it('opens full markdown modal via view-full button', () => {
67-
render(<SessionBanner bootContext={bootContext} />);
76+
renderWithRouter(<SessionBanner bootContext={bootContext} />);
6877
// Expand the banner
6978
fireEvent.click(screen.getByRole('button', { name: /5 sources/ }));
7079
// Click the ⧉ button
@@ -74,7 +83,7 @@ describe('SessionBanner', () => {
7483
});
7584

7685
it('closes modal on close button click', () => {
77-
render(<SessionBanner bootContext={bootContext} />);
86+
renderWithRouter(<SessionBanner bootContext={bootContext} />);
7887
fireEvent.click(screen.getByRole('button', { name: /5 sources/ }));
7988
fireEvent.click(screen.getByTitle('View full markdown'));
8089
expect(screen.getByText('Boot Context (Full Markdown)')).toBeTruthy();
@@ -84,7 +93,7 @@ describe('SessionBanner', () => {
8493
});
8594

8695
it('closes modal on Escape key', () => {
87-
render(<SessionBanner bootContext={bootContext} />);
96+
renderWithRouter(<SessionBanner bootContext={bootContext} />);
8897
fireEvent.click(screen.getByRole('button', { name: /5 sources/ }));
8998
fireEvent.click(screen.getByTitle('View full markdown'));
9099
expect(screen.getByText('Boot Context (Full Markdown)')).toBeTruthy();

frontend/src/hooks/useTaskBoard.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface TaskDisplayMeta {
1212
completedAgo?: string;
1313
elapsedLabel?: string;
1414
sessionHash?: string;
15+
sdkSessionId?: string;
1516
blockerSummary?: string;
1617
}
1718

@@ -57,7 +58,10 @@ function buildDisplayMeta(task: Task, now: number): TaskDisplayMeta {
5758
if (task.status === 'active' && task.claimedAt) {
5859
meta.elapsedLabel = formatDuration(now - task.claimedAt);
5960
}
60-
if (task.sessionId) {
61+
if (task.sdkSessionId) {
62+
meta.sessionHash = task.sdkSessionId.slice(0, 6);
63+
meta.sdkSessionId = task.sdkSessionId;
64+
} else if (task.sessionId) {
6165
meta.sessionHash = task.sessionId.slice(0, 6);
6266
}
6367
if ((task.status === 'blocked' || task.status === 'failed') && task.annotations.length > 0) {

frontend/src/pages/ChatView.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export function ChatView() {
5353
const clearPendingSession = useMitzoStore((s) => s.clearPendingSession);
5454
const sessionContext = useMitzoStore((s) => s.messages.sessionContext);
5555
const bootContext = useMitzoStore((s) => s.messages.bootContext);
56+
const sourceLink = useMitzoStore((s) => s.messages.sourceLink);
5657
const progressByToolId = useProgressByToolId();
5758

5859
const connected = connection.status === 'connected';
@@ -282,6 +283,7 @@ export function ChatView() {
282283
voice={voice}
283284
bootContext={bootContext}
284285
sessionContext={sessionContext}
286+
sourceLink={sourceLink}
285287
/>
286288

287289
<ChatInput

frontend/src/pages/DesktopChatView.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export function DesktopChatView() {
4141
const storeFetchSessionMeta = useMitzoStore((s) => s.fetchSessionMeta);
4242
const sessionContext = useMitzoStore((s) => s.messages.sessionContext);
4343
const bootContext = useMitzoStore((s) => s.messages.bootContext);
44+
const sourceLink = useMitzoStore((s) => s.messages.sourceLink);
4445
const progressByToolId = useProgressByToolId();
4546

4647
const connected = connection.status === 'connected';
@@ -252,6 +253,7 @@ export function DesktopChatView() {
252253
voice={voice}
253254
bootContext={bootContext}
254255
sessionContext={sessionContext}
256+
sourceLink={sourceLink}
255257
/>
256258
<ScrollFab scrollRef={scrollRef} />
257259
<ChatInput

frontend/src/styles/global.css

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2494,6 +2494,22 @@ textarea:focus {
24942494
font-style: italic;
24952495
}
24962496

2497+
.session-banner-source-link {
2498+
flex-shrink: 0;
2499+
font-size: var(--text-xxs);
2500+
color: var(--accent, #6366f1);
2501+
cursor: pointer;
2502+
text-decoration: underline;
2503+
text-decoration-style: dotted;
2504+
text-underline-offset: 2px;
2505+
opacity: 0.85;
2506+
white-space: nowrap;
2507+
}
2508+
.session-banner-source-link:hover,
2509+
.session-banner-source-link:focus-visible {
2510+
opacity: 1;
2511+
}
2512+
24972513
.session-banner-chevron {
24982514
margin-left: auto;
24992515
font-size: 0.6rem;
@@ -5631,6 +5647,19 @@ textarea:focus {
56315647
color: var(--task-failed);
56325648
}
56335649

5650+
.task-node-session-link {
5651+
cursor: pointer;
5652+
text-decoration: underline;
5653+
text-decoration-style: dotted;
5654+
text-underline-offset: 2px;
5655+
color: var(--accent);
5656+
opacity: 0.85;
5657+
}
5658+
.task-node-session-link:hover,
5659+
.task-node-session-link:focus-visible {
5660+
opacity: 1;
5661+
}
5662+
56345663
.task-node-meta {
56355664
font-size: var(--text-xs);
56365665
color: var(--text-dim);

frontend/src/types/task.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface Task {
2727
description: string | null;
2828
status: TaskStatus;
2929
sessionId: string | null;
30+
sdkSessionId: string | null;
3031
sessionPolicy: SessionPolicy;
3132
priority: number;
3233
depth: number;

packages/client/src/api-client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export interface SessionMetaResponse {
5757
totalCostUsd: number;
5858
numTurns: number;
5959
telosTaskId: string | null;
60+
goalId: string | null;
6061
}
6162

6263
export interface CalendarData {

0 commit comments

Comments
 (0)