[3dfc43a1] Task detail overhaul: markdown, navigation, collapsible sections, timestamps (#410)

* [35a27c3d] UX/UI: design task-detail overhaul (#404)

* [39ea1900] docs(ux_ui): add content-readability spec for markdown, collapsible sections, timestamps (#388)

Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech>

* [71f9aec6] docs(ux_ui): add task navigation/structure design spec (#400)

Adds docs/ux_ui/design/task-navigation-structure.md covering the
breadcrumb trail, prev/next sibling navigation, and a distinct visual
treatment for the read-only constraints section, grounded in the real
task-detail components and existing amber/Lock read-only tokens.

Co-authored-by: UX/UI Developer 2 <ux-dev-2@roboco.tech>

---------

Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech>
Co-authored-by: UX/UI Developer 2 <ux-dev-2@roboco.tech>

* [9baa1c34] Frontend: implement task-detail overhaul (#408)

* [13b6c723] Task detail: inline timestamps + breadcrumb + prev/next navigation (#390)

* [13b6c723] feat(panel): add inline absolute timestamps, task breadcrumb, and prev/next list nav to task detail

Adds a shared formatAbsoluteTimestamp helper used inline (with tooltip)
next to relative time on progress updates and checkpoints in
tab-progress.tsx, progress-timeline.tsx, and checkpoint-card.tsx.
Adds TaskBreadcrumb (renders only when task.parent_task_id is set) and
TaskListNav, which reads a new taskListNav context in the
scroll-restoration zustand store — populated by the Tasks list page from
TaskTable's live filtered/sorted order — to move to the adjacent task.
When no list context exists for the session or the current task isn't
part of the captured order, both nav buttons render disabled with an
explanatory tooltip (the documented fallback).

* [13b6c723] docs(guide): task detail navigation, timestamps, breadcrumb, and prev/next behavior

---------

Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

* [40acdd31] Task detail: collapsible markdown sections + distinct Constraints styling (#407)

* [40acdd31] feat(panel): collapsible task-detail sections + distinct Constraints styling

Wrap the Description, per-field Notes, and Plan cards in a new
CollapsibleSection (Radix Collapsible + tw-animate-css fade/slide, so
collapse/expand only animates opacity/transform) so a long task no longer
forces continuous scrolling. Restyle the read-only Constraints card with an
amber accent border, background tint, and ShieldAlert icon so it reads as
distinct from authored content. Existing edit/preview toggles are
force-open while active and otherwise unchanged. Adds a global
prefers-reduced-motion override in globals.css.

* [40acdd31] docs(panel): CollapsibleSection component API and usage guide

Documents the new CollapsibleSection wrapper component used for independent collapse/expand of task-detail sections (Description, Constraints, Notes, Plan). Covers component API, controlled vs. uncontrolled state patterns, animation behavior (fade+slide, transform/opacity only), prefers-reduced-motion handling, and usage examples across task-description.tsx / tab-notes.tsx / tab-plan.tsx.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

* [73f8311f] fix(task-table): remove exhaustive-deps suppression on visible-order effect (#409)

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>

---------

Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>

* [eb417ef1] Fix: apply auto-collapse thresholds to Progress and Acceptance Criteria surfaces (#429)

* [4e855d24] Apply content-readability-spec collapse thresholds to Progress and Acceptance Criteria surfaces (#416)

* [4e855d24] feat(task-detail): auto-collapse long progress/checkpoint/AC content per readability spec

* [4e855d24] refactor(task-detail): remove inline JSX section-marker comments per no-inline-comments convention

* [4e855d24] docs(task-detail): document content-readability-spec collapse thresholds for CollapsibleSection

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

* [3c90ef34] Wire content-readability thresholds into CollapsibleSection, tab-progress, acceptance-criteria (#430)

* [3c90ef34] test(task-detail): add AC4 combined readability test — 30+ progress entries + long acceptance-criteria list

* [3c90ef34] docs: enhance content-readability thresholds documentation and code comments

- Enhance panel/src/lib/content-readability.ts with usage examples and clarified intent
- Enhance CollapsibleSection with auto-collapse logic explanation and precedence rules
- Enhance TabProgress's RECENT_OPEN_COUNT logic with dual-threshold explanation
- Add comprehensive architecture guide: panel/docs/CONTENT_READABILITY_THRESHOLDS.md covering thresholds, components, testing, and implementation notes

The readability feature prevents long-history tasks (30+ updates, 20+ criteria) from rendering fully expanded, keeping pages navigable. Tests confirm 32 progress updates default to 2 open, and long criteria lists collapse while short ones stay expanded.

---------

Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

* [fc04d84a] Round-3 revision: fix 4 named gaps on task-detail overhaul, one dev leaf per fix (#455)

* [cac9b603] fix(panel): fall back to task.created_at for missing written_at stamp in tab-notes.tsx (#446)

Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>

* [31dd4f99] Remove ArrowLeft back button from task-header.tsx (#441)

* [31dd4f99] Remove ArrowLeft back button and Link wrapper from task-header.tsx, drop now-unused imports

* [31dd4f99] docs(task-navigation): mark spec as implemented, clarify ArrowLeft button removal

Update task-navigation-structure.md to reflect v0.21.0+ implementation:
- Status changed from "proposed" to "implemented"
- Clarified that ArrowLeft back button was removed from task-header.tsx
- Noted that breadcrumb and prev/next navigation now provide all navigation
- Constraints section styling with amber tint and ShieldAlert icon is complete
- Referenced related guide documentation for task-detail-navigation features

---------

Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

* [75fd7444] Wire content prop into EditableNoteCard's CollapsibleSection (#449)

* [75fd7444] feat(panel): wire content prop into EditableNoteCard's CollapsibleSection

Pass the note field's current value into CollapsibleSection's content
prop and derive EditableNoteCard's initial sectionOpen state from
exceedsReadabilityThreshold, so long notes default collapsed with an
expand affordance while short notes render fully expanded.

* [75fd7444] docs(panel): document EditableNoteCard's content-driven collapse pattern in collapsible-section.md

Updated docs/frontend/components/collapsible-section.md to reflect how EditableNoteCard in tab-notes.tsx uses both controlled mode (force-open while editing) and content-driven initialization (seed sectionOpen from content length). Added a new "Combined: controlled + content-driven initialization" example showing this pattern for future developers extending editable-content sections.

Pattern: long notes default collapsed with expand affordance, short notes default expanded, edit forms always visible during editing.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

* [18ada610] docs(ux-ui): reconcile prev/next nav design spec with shipped list-order behavior (#453)

Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>

---------

Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>

* [3dfc43a1] round-3 fixes: reconcile nav spec, Alt+Arrow shortcuts, CHANGELOG

The breadcrumb section of task-navigation-structure.md now describes the
shipped single-ancestor design (and drops the stale DropdownMenu claims);
Alt+ArrowLeft/Right on TaskListNav mirror the visible prev/next buttons,
suppressed while an editable element has focus, with tests; the
user-facing CHANGELOG entry lands under Unreleased. Also reflows the
round-1 content-readability-spec so the prose gate is green branch-wide.

* [3dfc43a1] blank line between Unreleased and 0.22.0 sections

---------

Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech>
Co-authored-by: UX/UI Developer 2 <ux-dev-2@roboco.tech>
Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-11 07:41:15 +02:00
committed by GitHub
co-authored by UX/UI Developer 1 UX/UI Developer 2 Frontend Developer 2 Frontend Documenter Frontend Developer 1 Renn F
parent f0f09b2204
commit eefaca1d3b
35 changed files with 2999 additions and 971 deletions
+1
View File
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Added
- **Task-detail overhaul.** Description, per-field Notes, Plan, Progress, and Acceptance Criteria now live in collapsible sections that auto-collapse past a content-length threshold (long progress/checkpoint history and long criteria lists default closed, short ones stay open) instead of forcing continuous scrolling. Progress updates and notes show an inline absolute timestamp next to the relative one, falling back to the task's creation time when a note has no timestamp of its own. A parent-task breadcrumb and prev/next buttons — with `Alt+ArrowLeft` / `Alt+ArrowRight` shortcuts — replace the old always-goes-to-`/tasks` back button, moving between the current task's parent and its neighbors in the last-visited Tasks list order. The read-only Constraints card gets a distinct amber accent, tint, and lock icon so it reads apart from the task's own authored description.
- **Playwright chromium headless verification for QA images.** `agent-qa-fe` and `agent-ux` (the shared UX/QA image) now ship Playwright with `chromium-headless-shell` pre-installed for browser-based QA verification — fe-qa and ux-qa agents can now launch headless chromium to verify rendered output, computed styles, a11y trees, and visual design when reading the diff alone cannot settle an acceptance criterion. A new CI workflow (`agent-image-smoke.yml`) builds real before/after images, runs a headless-launch smoke check inside each, and reports the real `docker inspect` size delta as a PR comment — the only non-estimated verification source available since agent sandboxes have no Docker daemon. See `docs/backend/qa/browser-verification.md` for examples and `fe-qa.md` / `ux-qa.md` identity prompts for the built-in guidance.
- **Playwright MCP server for QA's browser verification.** Hand-scripting the Playwright Python sync API through `Bash` is fragile for an agent, so `agent-qa-fe`/`agent-ux` now also ship `@playwright/mcp`, registered by the orchestrator as a `playwright` MCP server (`mcp__playwright__*`) for the `fe-qa`/`ux-qa` roles only — role-gated, not image-gated, so `be-qa` and `ux-dev` (which shares `agent-ux`'s image) never see it. A wrapper entrypoint (`docker/scripts/playwright-mcp-entrypoint.sh`) points the server at the image's existing baked `chromium-headless-shell` instead of letting it download a second browser. QA now drives the browser through structured `browser_navigate`/`browser_snapshot`/`browser_evaluate`/`browser_take_screenshot` tools instead of multi-line `Bash -c` Python strings; `agent-image-smoke.yml` verifies the binary + the resolved chromium path and takes a real headless screenshot of a live panel page from inside the ux-qa image.
@@ -0,0 +1,299 @@
# CollapsibleSection component
A reusable Card wrapper that enables independent collapse/expand of section content, allowing users to navigate long pages (like task-detail) without forcing continuous scrolling. Collapse/expand animations use only opacity and transform (no height/width), respecting prefers-reduced-motion globally.
## Purpose
When task-detail pages carry long descriptions, many notes, and detailed plans, users must scroll through all expanded content to reach later sections. `CollapsibleSection` wraps each logical section (Description, Constraints, Notes fields, Plan subsections) in a collapsible card so users can fold away irrelevant content and jump to what they need. The component supports both **controlled** (e.g., force-open while editing) and **uncontrolled** (stateless) modes.
## Files
| File | Role |
|------|------|
| `panel/src/components/tasks/task-detail/collapsible-section.tsx` | Component definition, `CollapsibleSectionProps` interface, state management. |
| `panel/src/components/tasks/task-detail/task-description.tsx` | Description and Constraints sections wrapped. Constraints styled with amber accent border/background + ShieldAlert icon for visual distinction. |
| `panel/src/components/tasks/task-detail/tab-notes.tsx` | Each editable note field (Description, Notes, Plan) wrapped. Edit/preview toggle is force-open while editing. |
| `panel/src/components/tasks/task-detail/tab-plan.tsx` | Approach, Sub-Tasks, Technical Considerations, Risks, and Open Questions sections wrapped. |
| `panel/src/app/globals.css` | Global `prefers-reduced-motion: reduce` override that disables all animations/transitions for users with reduced-motion enabled. |
## API
### `CollapsibleSectionProps`
```typescript
interface CollapsibleSectionProps {
/** Card title content (icon + text + badges as needed) */
title: ReactNode;
/** Right-aligned header controls (edit/preview toggles, buttons) — always visible */
actions?: ReactNode;
/** Controlled open state (e.g. force-open while a section is mid-edit). Omit for uncontrolled. */
open?: boolean;
/**
* Whether the (uncontrolled) section starts expanded. Takes precedence
* over `content`-derived collapsing. Omit to let `content` decide, or to
* default open when neither is given (so nothing visible today disappears).
*/
defaultOpen?: boolean;
/**
* Plain-text representation of the section's body, used to derive
* `defaultOpen` per the content-readability spec (~10 lines / ~640 chars)
* when `defaultOpen` is not explicitly set. Ignored otherwise.
*/
content?: string;
/** Callback when the user toggles the section open/closed. */
onOpenChange?: (open: boolean) => void;
/** Tailwind class string applied to the outer Card element. */
className?: string;
/** Tailwind class string applied to the CardHeader (title + actions row). */
headerClassName?: string;
/** Content rendered inside CardContent when the section is open. */
children: ReactNode;
}
```
### Component behavior
- **Uncontrolled mode** (omit `open` prop): component manages its own open state. `defaultOpen` determines initial state; if `defaultOpen` is omitted, the component uses `content`-derived collapsing (if `content` is provided), or defaults to `true` if neither is set. `onOpenChange` is called when the user clicks the toggle; internal state updates automatically.
- **Controlled mode** (`open` prop set): `onOpenChange` is called on toggle, but internal state is not updated; parent must update the `open` prop. Useful to force a section open while a user is editing (e.g., `open={isEditing || sectionOpen}`).
- **Content-driven defaultOpen** (new): when `content` is provided without an explicit `defaultOpen`, the component checks if the content exceeds the readability thresholds (~10 lines / ~640 characters, per `content-readability.ts`). If it does, the section defaults collapsed; otherwise, it defaults open. This keeps long lists/sections from forcing continuous scrolling. An explicit `defaultOpen` prop always takes precedence over this logic, maintaining backward compatibility with existing callers.
- **Title and actions**: title is always visible in the header; actions (right side) are also always visible, never collapsed away. This allows edit/preview toggles, save/cancel buttons, etc. to remain accessible.
- **ChevronDown icon**: rotates -90° when closed, 0° when open. Uses `transition-transform duration-200` so the rotation animates smoothly.
### Animation
Collapse/expand uses fade + slide from Tailwind CSS's `tw-animate-css` utilities:
```tsx
"duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:slide-out-to-top-1 data-[state=open]:slide-in-from-top-1",
```
- **Duration**: 200ms
- **Animation type**: fade (opacity) + slide (translateY), both controlled via transform/opacity CSS properties only — no height/width animation, so layout does not reflow mid-animation.
- **Accessibility**: `prefers-reduced-motion: reduce` is handled globally in `panel/src/app/globals.css`, which sets `animation-duration` and `transition-duration` to 0.01ms for all elements when the user has enabled reduced motion in their OS settings. The section content still opens/closes; it just doesn't animate.
## How to use
Wrap any section content that should be collapsible:
```tsx
"use client";
import { useState } from "react";
import { CollapsibleSection } from "./collapsible-section";
import { FileText, Edit3 } from "lucide-react";
export function MySection() {
const [sectionOpen, setSectionOpen] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const sectionText = "Section content here."; // Plain-text representation
return (
<CollapsibleSection
title={
<>
<FileText className="h-5 w-5" />
Section Title
</>
}
actions={
<Button size="sm" variant="ghost" onClick={() => setIsEditing(true)}>
<Edit3 className="h-4 w-4 mr-1" />
Edit
</Button>
}
content={sectionText} // Optional: drive defaultOpen based on content length
open={isEditing || sectionOpen}
onOpenChange={setSectionOpen}
>
<p>{sectionText}</p>
</CollapsibleSection>
);
}
```
### Using content-driven defaultOpen
To automatically collapse long sections without explicit `defaultOpen`:
```tsx
const listText = items.map(item => item.title).join("\n");
<CollapsibleSection
title="Long List"
content={listText} // Checked against ~10 lines / ~640 chars thresholds
>
<ul>
{items.map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
</CollapsibleSection>
```
If `listText` exceeds the readability thresholds, the section defaults collapsed; otherwise, it defaults open. No explicit `defaultOpen` prop needed.
### Controlled vs. uncontrolled
**Uncontrolled (simple case):**
```tsx
<CollapsibleSection title="Notes" defaultOpen={true}>
<p>Your notes content.</p>
</CollapsibleSection>
```
The component manages open state internally. `onOpenChange` is optional; if provided, it's called for logging/debugging, but state still updates automatically.
**Controlled (e.g., force-open while editing):**
```tsx
const [sectionOpen, setSectionOpen] = useState(true);
const [isEditing, setIsEditing] = useState(false);
<CollapsibleSection
title="Notes"
open={isEditing || sectionOpen}
onOpenChange={setSectionOpen}
>
{isEditing ? <textarea /> : <p>Rendered content.</p>}
</CollapsibleSection>
```
When `isEditing` is `true`, the section is forced open even if the user clicked to close it. This prevents an edit form from being hidden mid-interaction.
### Combined: controlled mode + content-driven initialization
For components that edit long content (like task notes), seed the initial collapsed/expanded state from content length, but use controlled mode to force-open during edit:
```tsx
const currentValue = task.dev_notes;
const [isEditing, setIsEditing] = useState(false);
const [sectionOpen, setSectionOpen] = useState(() =>
!exceedsReadabilityThreshold(currentValue ?? ""),
);
<CollapsibleSection
title="Developer Notes"
content={currentValue ?? undefined} // Drives content-readability check
open={isEditing || sectionOpen} // Controlled: force-open while editing
onOpenChange={setSectionOpen}
>
{isEditing ? <textarea value={currentValue} /> : <p>{currentValue}</p>}
</CollapsibleSection>
```
This pattern (used in `tab-notes.tsx`'s `EditableNoteCard`) ensures:
- Long notes default collapsed with an expand affordance
- Short notes default expanded (fully visible)
- Edit forms are always visible when editing, even if the user had collapsed the section
- User's collapse/expand choice persists across edit cycles (via `sectionOpen` state)
## Used in
The component is now applied across task-detail pages:
| Page / Component | Sections wrapped | Notes |
|---|---|---|
| `task-description.tsx` | Description, Constraints | Constraints section styled with amber border/background + ShieldAlert icon for visual distinction from authored content. |
| `tab-notes.tsx` | Each editable note field (dev_notes, qa_notes, doc_notes, etc.) | `EditableNoteCard` seeds initial `sectionOpen` from content length via `exceedsReadabilityThreshold`, passes `content={currentValue}` to CollapsibleSection, and forces open while editing via controlled `open={isEditing \|\| sectionOpen}`. Long notes default collapsed with expand affordance; short notes default expanded. |
| `tab-plan.tsx` | Approach, Sub-Tasks, Technical Considerations, Risks, Open Questions | Each sub-section independently collapsible. |
| `acceptance-criteria.tsx` | Full acceptance criteria list | Wrapped in CollapsibleSection with `content={criteriaText}`, so a long AC list defaults collapsed per content-readability spec. Forced open while adding/editing via controlled `open` prop. |
| `tab-progress.tsx` | Individual progress updates and checkpoints (via internal Radix Collapsible wrapper) | Each entry wrapped in a collapsible section; only the 2 most recent entries default open (gated by content length as well). Older entries default collapsed even if short, keeping task detail navigable without endless scrolling. |
## Design decisions
- **Content-driven defaultOpen (new)**: instead of always defaulting open (which forced tasks with long histories to be fully expanded), the component now checks content length against readability thresholds (~10 lines / ~640 characters) when `defaultOpen` is not explicitly set. Long content defaults collapsed, keeping task-detail pages navigable. The explicit `defaultOpen` prop always takes precedence, so existing callers (task-description, tab-notes, tab-plan) that pass `open={...}` are unaffected — the content-length check only applies to uncontrolled sections. This is the "content-readability spec" driving progress and AC collapse in tab-progress and acceptance-criteria.
- **Fade + slide animation only**: opacity and transform are GPU-accelerated and don't trigger layout reflow. Height/width animations are avoided because they force the browser to recalculate layout mid-animation, causing jank on slower devices and making the motion distracting.
- **Controlled + uncontrolled modes**: uncontrolled is the default for simple read-only sections (no extra state management needed), while controlled mode (via `open` prop) lets parent components force a section open during edit (the common case for tab-notes EditableNoteCard).
- **Always-visible actions**: the actions slot (buttons, toggles) is never collapsed, so users can always edit, delete, or perform actions on a section without expanding it first.
- **ChevronDown icon rotates, not replaces**: using a rotating icon is more intuitive and uses less real estate than swapping between two different icons (Chevron-Down vs. Chevron-Up).
- **Global prefers-reduced-motion override**: instead of checking `prefers-reduced-motion` in JavaScript (which is error-prone and scattered across components), a single global CSS rule ensures that **all** animations and transitions respect the user's OS setting. No component logic needed.
- **`CardTitle` inside the trigger**: the title is inside a styled `<button>` (the CollapsibleTrigger) so it's keyboard-accessible and screenreader-labeled. The button is full-width (flex-1) and text-left, so users can click anywhere on the title to toggle.
## Testing
The test suite covers:
- Rendering a section with title, children, and optional actions.
- Toggling the section open/closed on click.
- Checking `aria-expanded` attribute on the trigger button.
- Verifying that content is hidden when closed (not just visually; `CollapsibleContent` removes it from the DOM).
- Controlled vs. uncontrolled state management.
- Animation classes applied to `CollapsibleContent` based on open state.
- ChevronDown icon rotation (CSS class toggling).
- Always-visible actions slot (buttons not collapsed away).
See `panel/src/components/tasks/task-detail/__tests__/task-description.test.tsx` for integration tests covering:
- Independent collapse of Description and Constraints sections.
- Controlled open state while editing.
- Edit/preview toggle working alongside collapse behavior.
## Related work
- **Task detail: collapsible markdown sections + distinct Constraints styling** — this commit. Introduces `CollapsibleSection` and applies it to task-description.tsx, tab-notes.tsx, tab-plan.tsx. Constraints section gets distinct amber styling.
## Migration / rollout
For developers adding a new collapsible section to task-detail or other pages:
1. Import `CollapsibleSection` from `./collapsible-section` (adjust path as needed).
2. Wrap the section content, provide a `title` (can include icons, badges).
3. Optionally provide `actions` (buttons, toggles that should stay visible).
4. **For sections with potentially long content** (lists, histories): pass a plain-text `content` prop so the section automatically collapses if the content is long. This defers to the content-readability spec thresholds (~10 lines / ~640 chars).
5. For editable content, use the controlled pattern: `open={isEditing || sectionOpen}` to force-open while editing. The controlled `open` prop takes precedence over content-driven collapse.
6. No extra state management is needed for read-only sections; the component handles it internally.
Example (uncontrolled with content-driven collapse):
```tsx
const listText = items.map(item => item.name).join("\n");
<CollapsibleSection
title="My List"
content={listText} // Drives defaultOpen based on length
>
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</CollapsibleSection>
```
Example (controlled, forcing open during edit):
```tsx
const [sectionOpen, setSectionOpen] = useState(true);
const [isEditing, setIsEditing] = useState(false);
<CollapsibleSection
title="My Section"
open={isEditing || sectionOpen} // Controlled: explicit `open` wins over content
onOpenChange={setSectionOpen}
>
{isEditing ? <textarea /> : <p>Content here.</p>}
</CollapsibleSection>
```
## Accessibility
- **`aria-expanded` attribute** on the trigger button communicates the open/closed state to screenreaders.
- **Keyboard support**: the trigger is a `<button>`, so it's focusable with Tab and activatable with Enter/Space.
- **`aria-hidden="true"` on the ChevronDown icon**: the icon is decorative; screenreaders skip it.
- **`prefers-reduced-motion` support**: animations are disabled globally for users with motion sensitivity, but the section still opens/closes (the content is not hidden, just instant).
+123
View File
@@ -0,0 +1,123 @@
# Content Readability Helper (`content-readability.ts`)
A shared utility that defines thresholds and a checker for auto-collapsing long content in the task-detail view. It prevents tasks with long histories (many progress updates, checkpoints, or acceptance criteria) from forcing continuous scrolling through fully-expanded sections.
## Rationale
When a task accumulates 30+ progress entries, 20+ checkpoints, or a long acceptance-criteria list, rendering all entries open by default fills the viewport. Users must scroll through every item to reach later sections. This helper establishes a readability-driven threshold so long content defaults collapsed while short content stays visible.
## Thresholds
| Name | Value | Purpose |
|------|-------|---------|
| `READABILITY_LINE_THRESHOLD` | 10 | Content exceeding this many lines is considered "long" |
| `READABILITY_CHAR_THRESHOLD` | 640 | Content exceeding this character count is considered "long" |
A section defaults **collapsed** if either threshold is exceeded (OR logic). A section defaults **open** if both are satisfied (short by both measures).
## API
### `exceedsReadabilityThreshold(content: string): boolean`
Returns `true` if the content exceeds readability thresholds, indicating it should default collapsed.
```tsx
import { exceedsReadabilityThreshold } from "@/lib/content-readability";
const listText = items.map(i => i.name).join("\n");
const shouldCollapse = exceedsReadabilityThreshold(listText);
```
**Parameters:**
- `content` (string): Plain-text representation of the section body. Line breaks are respected; a multi-line string counts as multiple lines.
**Returns:**
- `true` if lineCount > 10 OR content.length > 640
- `false` otherwise
**Edge case:** empty or falsy content always returns `false` (empty content is always "readable" and defaults open).
## Usage in CollapsibleSection
`CollapsibleSection` integrates this checker via its optional `content` prop:
```tsx
<CollapsibleSection
title="Acceptance Criteria"
content={criteriaText} // Plain-text list of all criteria, newline-joined
>
<ul>
{criteria.map(c => (
<li key={c.id}>{c.text}</li>
))}
</ul>
</CollapsibleSection>
```
If `content` is provided without an explicit `defaultOpen`, the component calls `exceedsReadabilityThreshold(content)` and defaults:
- **Collapsed** if `exceedsReadabilityThreshold` returns `true`
- **Open** if it returns `false`
An explicit `defaultOpen` prop always takes precedence, bypassing the content check.
## Usage in Tab Progress / Checkpoints
`tab-progress.tsx` wraps each progress update and checkpoint entry in a Radix Collapsible (not CollapsibleSection). A `defaultEntryOpen(idx, content)` function keeps only the **2 most recent entries open** (regardless of content length) and collapses all older entries. This applies even to short entries: older updates are collapsed to keep the list scannable.
```tsx
// Pseudo-code: each update is wrapped
<Collapsible
defaultOpen={defaultEntryOpen(idx, updateContent)}
// ... which evaluates to:
// - true if idx < 2 AND content is short
// - false if idx >= 2 OR content is long
>
{/* update body */}
</Collapsible>
```
Test coverage: `tab-progress-collapse.test.tsx` verifies that a 30-entry task has entries 01 open and entries 229 closed, even if all entries are short.
## Rationale for thresholds
- **10 lines**: Enough to show a short paragraph or a 5-6 item list without scrolling the section itself. Most prose descriptions fit. A checkbox-list item typically takes 1 line; 10 items fits comfortably.
- **640 characters**: ~45 sentences of prose, or ~20 short list items (32 chars/item). Roughly equivalent to 10 lines of average content. The threshold allows either metric to trigger collapse independently (a single very-long line counts; a tall list of short lines counts).
Both must be satisfied to keep content open. This prevents edge cases where a small number of extremely long lines OR a tall list of 1-char items each individually pass but together are unreadable.
## Testing
`collapsible-section.test.tsx` covers the content-driven defaultOpen behavior:
- Short content stays open by default
- Long content (exceeding either threshold) defaults closed
- Explicit `defaultOpen` prop overrides content-driven behavior
- Controlled `open` prop is unaffected by content length
Example:
```tsx
it("defaults collapsed when content exceeds the readability thresholds", () => {
render(
<CollapsibleSection
title="Section"
content={"a".repeat(READABILITY_CHAR_THRESHOLD + 1)}
>
<p>body</p>
</CollapsibleSection>,
);
expect(screen.getByRole("button", { name: "Section" })).toHaveAttribute(
"aria-expanded",
"false",
);
});
```
## Related work
- **CollapsibleSection component** (`collapsible-section.tsx`): integrates this helper via the `content` prop
- **AcceptanceCriteria** (`acceptance-criteria.tsx`): wraps the full criteria list in CollapsibleSection with `content={criteriaText}`
- **TabProgress** (`tab-progress.tsx`): wraps each update/checkpoint entry in a Radix Collapsible with `defaultEntryOpen(idx, content)`
## Backward compatibility
Existing consumers of `CollapsibleSection` (task-description, tab-notes, tab-plan) all pass an explicit `open` prop (controlled mode), so the content-readability check does not apply to them. This change is additive: no existing behavior changes; new consumers can opt into content-driven collapse by omitting `defaultOpen` and providing `content`.
+182
View File
@@ -0,0 +1,182 @@
# Task Detail Navigation & Timestamps
This guide documents three related UI features added to the task-detail page: inline absolute timestamps, parent-task breadcrumbs, and list-context-aware prev/next navigation.
## Inline Absolute Timestamps
**Location:** `panel/src/lib/utils.ts`, `formatAbsoluteTimestamp()`
All progress updates and checkpoints now display a standardized absolute timestamp (e.g., "Jul 10, 2026, 3:45 PM") **in addition to** the existing relative time text (e.g., "2m ago"). The absolute format is consistent across the panel, achieved via a shared helper that was extracted from the earlier `checkpoint-card.tsx` local implementation.
### Implementation
```typescript
// lib/utils.ts
export function formatAbsoluteTimestamp(timestamp: string): string {
return new Date(timestamp).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
```
### Usage in Components
- **`tab-progress.tsx` ProgressUpdatesSection:** Each progress entry now shows relative time + `·` + absolute time on the same line, with the absolute time also in the `title` tooltip for hover clarity.
- **`tab-progress.tsx` CheckpointsSection:** Same treatment as progress updates.
- **`progress-timeline.tsx`:** In the timeline view, relative time + absolute time on the same line.
- **`checkpoint-card.tsx`:** The checkpoint component now uses the shared helper instead of its own format.
The relative time remains the primary visual — it's glanceable. The absolute time is the secondary, precise reference, useful when comparing timestamps across different time zones or when exact dates matter (e.g., "was this before or after June 30?").
## Parent Task Breadcrumb
**Location:** `panel/src/components/tasks/task-detail/task-breadcrumb.tsx`
When a task has a parent, a breadcrumb renders above the task title: `Parent Title > Child Title`. The breadcrumb is absent for root tasks (those with `parent_task_id = null`), keeping the UI clean for single-level work.
### Implementation Details
- Renders only if `task.parent_task_id` is set; otherwise returns `null`.
- Uses `useTask(parentId)` to fetch the parent task data (title + styling).
- While loading, a skeleton placeholder avoids layout shift.
- The parent title is a clickable link to `/tasks/{parent.id}`.
- Long titles are truncated with a `title` attribute tooltip.
- Only the immediate parent is shown — deeper ancestry is reachable by following the chain one hop at a time, matching how the rest of the panel represents task hierarchy.
### Example
If Task B is a child of Task A:
- Viewing Task B shows: `Task A > Task B`
- Clicking "Task A" navigates to that parent
- If Task A also has a parent, viewing Task A shows that grandparent, not Task B
This recursive, one-level-at-a-time model prevents breadcrumbs from becoming unwieldy and mirrors the panel's task-tree representation elsewhere.
## Prev/Next Task Navigation
**Location:** `panel/src/components/tasks/task-detail/task-list-nav.tsx`
Two chevron buttons (← →) on the task-detail page move to the adjacent task **within the current Tasks list filter/sort context**. This preserves the user's list view experience: if they filtered by status, sorted by priority, or searched for a term, those same filters apply when they navigate prev/next.
### How It Works
1. **List context capture:** The Tasks page (`tasks/page.tsx`) passes the currently visible task IDs (id + title) to `useScrollRestorationStore.setTaskListNav()` whenever the table's filtered/sorted order changes. The context includes the full query string (filters, search, sort) so a "Back to list" link can restore the exact view.
2. **Session-scoped state:** The context lives in `sessionStorage` via Zustand's persist middleware. It survives navigation within the same browser session but is cleared if the browser is closed. This matches the "come back where you left off" UX without persisting across sessions.
3. **Fallback when no context:** If the user navigates to a task via a direct link, search result, notification, or any path that doesn't pass through the Tasks list, `taskListNav` is `null`. Both buttons render **disabled** with a tooltip explaining: "Open this task from the Tasks list to enable prev/next navigation within that list's filter/sort order." This is the **documented fallback** — no guessing, no silent behavior change.
4. **Edge cases:**
- **First item in list:** Prev button is disabled; next is enabled.
- **Last item in list:** Next button is disabled; prev is enabled.
- **Task not in captured list:** Both buttons are disabled (task was navigated to via another route after the list was visited).
- **Query string preservation:** The href for each nav link includes the original query string, so navigating back to the Tasks page from a nested task restores the same filters.
### Components
**`TaskListNav`:** The main export. Reads `taskListNav` from the store, computes prev/next items, and renders two `NavButton` children.
**`NavButton`:** A single direction button. If enabled, it wraps a Next.js `<Link>`; if disabled, it's a plain button with a tooltip explaining why. The tooltip shows either the next task's title (when enabled) or the reason it's disabled.
### Implementation Example
```typescript
export function TaskListNav({ task }: TaskListNavProps) {
const context = useScrollRestorationStore((state) => state.taskListNav);
const items = context?.items ?? [];
const index = items.findIndex((item) => item.id === task.id);
const hasContext = index !== -1;
const prevItem = hasContext && index > 0 ? items[index - 1] : null;
const nextItem =
hasContext && index < items.length - 1 ? items[index + 1] : null;
const query = context?.queryString ? `?${context.queryString}` : "";
// Render NavButton for each direction with the computed item and query
}
```
## Store Extension
**Location:** `panel/src/lib/stores/scroll-restoration-store.ts`
The `useScrollRestorationStore` (already managing scroll positions, section expansions, etc.) now includes:
- **`taskListNav: TaskListNavContext | null`** — The current task list context, or `null` if no list has been visited.
- **`setTaskListNav(context: TaskListNavContext)`** — Updates the context when the Tasks table reports a new visible order.
### Schema
```typescript
export interface TaskListNavItem {
id: string;
title: string;
}
export interface TaskListNavContext {
items: TaskListNavItem[];
queryString: string; // e.g., "status=in_progress&sort=-created_at"
}
```
The store uses `sessionStorage` persistence, so state is automatically restored on page reload within the same session but is cleared when the session ends.
## Integration Points
### Tasks List Page (`tasks/page.tsx`)
Calls `setTaskListNav()` whenever the table's visible order changes:
```typescript
const setTaskListNav = useScrollRestorationStore((state) => state.setTaskListNav);
const handleVisibleOrderChange = useCallback(
(items: { id: string; title: string }[]) => {
setTaskListNav({ items, queryString: searchParamsString });
},
[setTaskListNav, searchParamsString],
);
```
### TaskTable (`components/tasks/task-table.tsx`)
Now accepts an optional `onVisibleOrderChange` callback, fired whenever the computed visible task order changes (e.g., on filter, sort, pagination, or expansion/collapse). The Tasks page wires this callback to capture the order.
### Task Detail Page (`[taskId]/page.tsx`)
Renders both `TaskBreadcrumb` and `TaskListNav` near the top of the page, in a flex row:
```tsx
<div className="flex items-center justify-between gap-4">
<TaskBreadcrumb task={task} />
<TaskListNav task={task} />
</div>
```
The breadcrumb takes the left; nav buttons take the right, preserving the layout for long parent titles.
## Design Considerations
### Why relative + absolute times?
Relative times ("2m ago") are glanceable but ambiguous across time zones and when comparing entries hours or days apart. Absolute times are precise but verbose if shown alone. Combining them gives quick context (relative) + accuracy (absolute) without sacrificing space.
### Why session-scoped, not persistent?
Task lists reflect current filters, which change frequently. Persisting a stale list order across sessions (e.g., "I opened this task from a filtered search yesterday, but I reopened the browser and the filter is gone") would silently navigate to the wrong next task. Session scope keeps the invariant: prev/next only works within the current session's list context.
### Why disable instead of guess?
A task opened via direct link, notification, or external system doesn't have a parent list context. We could try to infer one (e.g., "show all tasks" or "show tasks in this project"), but that's invisible magic — the user wouldn't know why they're seeing a particular next task. Disabling with a clear explanation ("open from the list to enable this") is explicit and honest.
## Future Extensions
Possible enhancements:
1. **Remember list context across sessions:** A "pin this list" feature could persist context across browser sessions.
2. **Breadcrumb depth:** Allow showing full ancestry (A > B > C) instead of one level; would require more space and careful truncation.
3. **Recently viewed tasks:** A dropdown of tasks you've visited recently, separate from the list context.
4. **Keyboard shortcuts:** Arrow keys to navigate prev/next when the buttons are enabled.
@@ -0,0 +1,80 @@
# Content-readability spec: markdown, collapsible sections, timestamps
Status: proposed Owner: ux-dev-1 Surface: task detail panel (`panel/src/components/tasks/task-detail/`) and any other view that renders task-authored markdown or an activity/notes feed (journals, A2A transcript).
## Dial read
Per the team design bar, this is dense product UI (task detail / admin panel), not a marketing surface:
- **DESIGN_VARIANCE:** 2 — predictable, symmetric card layout. Collapsing content changes height, not position; no asymmetric grid.
- **MOTION_INTENSITY:** 2 — a single `transform`/`opacity`/`grid-template-rows` expand-collapse transition on the section body, nothing else animates.
- **VISUAL_DENSITY:** 8 — tight padding, no added chrome beyond what already exists in `Card`, tabular timestamps in list contexts.
## Problem
Long task bodies (descriptions, dev/QA/PR-reviewer notes, journal entries) render in full with no way to collapse them, so a task with a large plan or a long QA note pushes the rest of the tab below the fold — this is the "scrolling fatigue" the acceptance criteria name. Separately, the panel currently renders timestamps three different ways depending on which component you're looking at:
| Component | Format | Example |
|---|---|---|
| `progress-timeline.tsx` `formatTime()` | relative bucket, falls back to short absolute past 7 days | `"3h ago"` / `"Jul 3, 02:14 PM"` |
| `tab-notes.tsx` `writtenAt()` | always absolute, no time-of-day granularity beyond minutes | `"Jul 10, 04:41 AM"` |
| `a2a-transcript.tsx` | `date-fns` `formatDistanceToNow`, always relative, no absolute fallback | `"3 hours ago"` |
Three renderers means three different date-math implementations to keep correct and three different reading experiences in the same activity feed. This spec fixes both problems without introducing a new dependency — `date-fns` is already installed and used by `a2a-transcript.tsx`.
## 1. Collapsible-section pattern
Use the existing Radix wrapper (`panel/src/components/ui/collapsible.tsx`, `Collapsible`/`CollapsibleTrigger`/`CollapsibleContent`) — do not add a new library or hand-roll a `useState` show/hide toggle for this; the primitive already handles the `data-state`/animation attributes lint components key off.
**Trigger condition.** A markdown body collapses when its rendered content would exceed **~10 lines or ~640 characters of source markdown**, whichever comes first — measured on the raw markdown string before render, not the rendered DOM height, so the decision is synchronous and doesn't require a layout pass. Content under that threshold renders inline with no trigger/affordance at all (a collapse control on a 2-line note is chrome for its own sake).
**Default state by field, not a single global rule** — a reader's expectation of "do I need this right now" differs per field:
| Field / feed | Default state | Rationale |
|---|---|---|
| Task `description` | **open** | The task's own brief — the reason the reader opened the task. |
| `dev_notes` / `qa_notes` / `pr_reviewer_notes` / `auditor_notes` / `doc_notes` | **collapsed** if over threshold | Historical record; opened on demand while triaging or auditing, not on first load. |
| `quick_context` (resumption) | **open** | Explicitly the field a resuming agent/PM needs first. |
| Progress-timeline / journal entries (list items) | **collapsed**, most recent 2 entries **open** | Matches "activity feed" convention — recent items visible, older ones summarized. |
| Task `constraints` | **collapsed** | Read-only, project-wide, rarely the thing a reader is here for. |
**Affordance.** The `CollapsibleTrigger` wraps a text button reading `Show more (N lines)` / `Show less`, right-aligned in the existing `CardHeader` row those components already use (see `tab-notes.tsx`'s header `flex items-center justify-between`) — no new header layout. Use a `ChevronDown` (`lucide-react`, already a dependency) that rotates 180° on open via `data-[state=open]:rotate-180 transition-transform`, matching the `MOTION_INTENSITY: 2` budget (transform only, no scroll listener).
**Persistence.** Collapse state is component-local (`useState`), not persisted to the task record or localStorage — a reader re-opening the tab should see the field-default state again, not their last session's toggle. This keeps the change purely presentational with zero backend/schema touch.
**Accessibility.** `CollapsibleTrigger` already renders `aria-expanded` via the Radix primitive; keep the visible label text in sync with that state (`"Show more"` / `"Show less"`, not just an icon) so screen readers get the same information sighted users do.
## 2. Markdown rendering treatment
The existing `Markdown` component (`panel/src/components/ui/markdown.tsx`) already defines the full token set — this spec does not introduce a second markdown renderer or a competing prose scale. It formalizes which of its two existing modes (`compact` vs. default) each surface should use, since that choice is currently made ad hoc per call site:
- **Default mode** (`prose-headings:font-bold`, `h1` 2xl / `h2` xl with a bottom border / `h3` lg / `h4` base, `prose-pre:bg-muted prose-pre:border prose-pre:rounded-lg`) is for content the reader is reading top-to-bottom as a document: task `description`, `dev_notes`/`qa_notes`/etc. bodies, journal entries, constraints.
- **`compact` mode** (uniform `text-xs` headings, hidden `pre` blocks, tight list/paragraph spacing) is for anything rendered inside a list row or card summary where markdown is secondary to the surrounding metadata — e.g. a one-line progress-update message or an A2A transcript bubble. Do not disable `compact`'s `prose-pre:hidden` for these contexts: a code block inside a chat bubble already breaks density, and this spec's threshold-based collapse (§1) is the mechanism for anyone who needs the full content, not an inline fenced-block render.
- **Code blocks** (default mode only, since `compact` hides them): rely on the existing `prose-pre:bg-muted prose-pre:border prose-pre:rounded-lg` plus inline-code's `prose-code:bg-muted prose-code:rounded prose-code:font-mono` — this already matches the panel's card/muted tokens, so no new color is introduced. No syntax highlighting — out of scope; a highlighter is a new dependency this spec's content doesn't justify (ponytail: revisit only if a future task specifically needs highlighted diffs/code review inline).
- **Lists**: GFM task-list checkboxes already render via the `Checkbox` component when `onCheckboxChange` is supplied (editable contexts: description, notes) and as a plain styled `input[type=checkbox]` otherwise (read-only contexts: journals, PR bodies) — keep that split; do not force every consumer to wire up `onCheckboxChange`.
No changes to `markdown.tsx`'s Tailwind token classes are proposed — they already satisfy this criterion. What was missing was a documented rule for *which mode a new call site should pick*, captured above.
## 3. Timestamp presentation format
**One format, applied everywhere a note or progress/journal entry shows a timestamp:** a relative bucket for anything within the last 7 days, an absolute short date+time beyond that — this is `progress-timeline.tsx`'s existing `formatTime()` behavior, and it becomes the canonical implementation the other two call sites adopt instead of maintaining their own date math:
```
< 1 min "Just now"
< 60 min "{n}m ago"
< 24 h "{n}h ago"
< 7 d "{n}d ago"
>= 7 d "Jul 3, 2:14 PM" (Intl "MMM d, h:mm a", locale en-US)
```
**Always-visible absolute timestamp on hover/focus.** Every timestamp element carries a `title` attribute with the full ISO-derived absolute stamp (`"Jul 3, 2026, 2:14:32 PM"`), so a relative bucket never fully hides the precise time — this is a one-line addition to each call site, no new component required beyond the shared formatter.
**Consolidation, not three rules.** Extract `progress-timeline.tsx`'s `formatTime()` into a shared helper (e.g. `panel/src/lib/format-timestamp.ts`, matching where other date/agent-name helpers already live like `lib/agent-utils.ts`) and have `tab-notes.tsx`'s `writtenAt()` and `a2a-transcript.tsx`'s `formatDistanceToNow(...)` call replaced with it. This removes two of the three divergent implementations rather than adding a fourth. `date-fns` stays a dependency (already used elsewhere in the panel) but this specific formatter does not need it — the bucket math is short enough to stay plain `Date` arithmetic, consistent with `formatTime()`'s current implementation, so `format-timestamp.ts` has no new dependency to justify.
**Where this applies:** progress updates, journal/note entries in every task-detail tab (`quick_context`/`dev_notes`/`qa_notes`/`pr_reviewer_notes`/ `auditor_notes`/`doc_notes` "written at" stamps), and the A2A transcript. Any future feed showing a note/progress entry should reuse the same helper rather than writing a fourth formatter.
## Non-goals
- No new component library or animation dependency — everything above composes `Collapsible`, `Markdown`, and `date-fns`/plain `Date`, all already in the panel's dependency tree.
- No change to how markdown is stored or how notes are written (backend, `notes_structured`, gateway verbs) — this is purely a rendering-layer spec.
- Syntax highlighting, persisted collapse-state, and a global "collapse all" control are explicitly out of scope for this pass.
@@ -0,0 +1,90 @@
# Navigation/structure spec: breadcrumb, prev/next, constraints distinction
Status: implemented (v0.21.0+) Owner: ux-dev-2 Surface: task detail page (`panel/src/app/(dashboard)/tasks/[taskId]/page.tsx`) and its header (`panel/src/components/tasks/task-detail/task-header.tsx`) and description tab (`panel/src/components/tasks/task-detail/task-description.tsx`).
## Dial read
Per the team design bar, this is dense product UI (task detail / admin panel):
- **DESIGN_VARIANCE:** 2 — the breadcrumb and prev/next controls are a predictable single row above the existing header; no new grid or layout shape.
- **MOTION_INTENSITY:** 1 — hover/focus states only (existing `Button` / `Link` hover treatment), no transition is introduced.
- **VISUAL_DENSITY:** 8 — compact 28px-tall controls that add one row of chrome, nothing more; the constraints card stays a `Card`, not a heavier modal or full-width banner.
## Problem
The task detail page currently has no sense of *where this task sits*: the header's only navigation is a single `ArrowLeft` icon button that always goes to `/tasks` (`task-header.tsx` lines 474-479), regardless of whether the task has a parent. A reader drilling into a subtask of a subtask loses the parent chain the moment they land on the page, and moving between sibling tasks (e.g. checking each dev subtask of the same parent while triaging) requires going back to the list and re-finding the next one every time.
Separately, `task.constraints` (the auto-attached project-wide architectural standard, read-only) renders in `task-description.tsx` as a `Card` with only `border-dashed` and a muted title (lines 181-196) — visually one dash away from the free-form, user-authored `description` card right above it. A reader skimming the page has no fast visual cue that one box is "the project's rule" and the other is "this task's own words."
This spec is a pure design/markup change: no new dependency, no new backend field (`parent_task_id`, `sequence`, and `project_id` already exist on `Task`; `useTask` and `useSubtasks` already exist in `@/hooks/use-tasks`).
## 1. Breadcrumb trail
**Implementation status:** ✓ Complete (v0.21.0+). This diverged from the rich multi-crumb trail originally proposed for this section — see "What shipped" below for the design that actually rendered.
**Component:** `TaskBreadcrumb` at `panel/src/components/tasks/task-detail/task-breadcrumb.tsx`, rendered in the task detail page (`[taskId]/page.tsx`) above the `TaskHeader` component, replacing the standalone `ArrowLeft` button that was removed from `task-header.tsx`.
**What shipped.** A single ancestor level, not the multi-crumb trail below: `{Parent title} > {Current title}`. It renders only when `task.parent_task_id` is set — `null` for a root task, so the row is simply absent rather than showing an empty or placeholder crumb. The parent is fetched with one `useTask(parentId)` call (a `Skeleton` covers the loading gap); the parent title is a `Link` to `/tasks/{parent.id}`, truncated with a `title=` tooltip; the current task's own title renders last, non-interactive, in `text-foreground/70`. Separator: `ChevronRight` (`lucide-react`). Deeper ancestry is reached by following the chain up one hop at a time — clicking into the parent shows its own parent, and so on — rather than being flattened into one row.
**Why not the rich trail.** The proposed `Tasks` crumb existed to carry the back-to-list affordance the standalone `ArrowLeft` button used to own; that's redundant with the persistent sidebar's own "Tasks" nav link (`components/layout/sidebar.tsx`), so dropping it doesn't remove a control, it removes a duplicate. The `{Project name}` crumb and the multi-ancestor walk (a `useTask(parentId)` fetch per generation, collapsing behind a `DropdownMenu` past 3 entries) added fetches and layout branching for a case — chains long enough to need collapsing — that's rare given hierarchies cap at 3 levels per `CLAUDE.md`'s MegaTask section; the one-hop-at-a-time model covers it with a fifth of the markup. Full writeup: `docs/guide/task-detail-navigation.md`.
**Accessibility.** The row carries `aria-label="Parent task"`; the current-task span is plain non-interactive text, not a focusable element.
## 2. Prev/next list navigation
**Implementation status:** ✓ Complete (v0.21.0+). List-context-aware navigation, documented separately in `docs/guide/task-detail-navigation.md`.
**Component:** `TaskListNav` at `panel/src/components/tasks/task-detail/task-list-nav.tsx`, rendered in the task detail page (`[taskId]/page.tsx`) alongside the breadcrumb — two chevron icon buttons that move to adjacent tasks within the current Tasks list filter/sort context, or disabled when viewed outside the list context.
**Data & ordering.** This diverged from the original sibling-order proposal during implementation (see `docs/guide/task-detail-navigation.md` for the full writeup) — "adjacent" means the previous/next row in the **Tasks list's last-visited filter/sort order**, not a `parent_task_id` sibling. The Tasks list page (`tasks/page.tsx`) reports its currently visible, filtered/sorted task order to `useScrollRestorationStore.setTaskListNav({ items, queryString })` whenever it changes; `TaskListNav` reads that `taskListNav` context, finds `task.id`'s index in `items`, and derives the prev/next item from `index - 1` / `index + 1`. The context is session-scoped (`sessionStorage` via the existing Zustand `persist` middleware), so it survives navigation within a session but doesn't persist across sessions. `useSubtasks` / `parent_task_id` / `sequence` are not part of this feature — sibling-order navigation, as originally proposed below, was not what shipped.
**Controls.** Two `Button variant="outline" size="icon"` using `ChevronLeft` / `ChevronRight` (`lucide-react`), each wrapped in a `Tooltip`:
- **Prev** links (`Link href="/tasks/{id}{queryString}"`) to `items[index - 1]`; **disabled** (not hidden — a disabled control at the boundary communicates "this is the first one," an absent control reads as "there is no prev/next feature here") when the current task is first in the captured list order, or when no list context exists for this session, or when the current task isn't part of the captured order (opened via a direct link, search, or notification instead of from the list).
- **Next** mirrors this at `index + 1`, disabled when current is last (or the same no-context/not-in-list cases as Prev).
- Each button's tooltip shows the target item's `title` when enabled, or the fixed explanation "Open this task from the Tasks list to enable prev/next navigation within that list's filter/sort order." when disabled — so hovering a disabled button still explains why, rather than looking broken.
- The href carries the captured `queryString` (the Tasks list's filters/sort at the time it was visited), so navigating there preserves that view.
**Keyboard.** ✓ Implemented. `Alt+ArrowLeft` / `Alt+ArrowRight` navigate prev/next exactly as the visible buttons do (same disabled-at-boundary and no-context behavior — the shortcut is a no-op when the corresponding button would be disabled). A `window` `keydown` listener in `TaskListNav` is suppressed whenever the focused element is an `input`, `textarea`, or `contenteditable` node, so it doesn't fight text-field cursor movement.
**Empty state.** When no list context has been captured this session, or the current task isn't found in the captured `items`, both buttons render disabled with the fallback tooltip above rather than being omitted — consistent with the boundary-disabled treatment, so the control's presence is predictable regardless of which task the reader is on.
## 3. Constraints visual distinction
**Implementation status:** ✓ Complete (v0.21.0+). Constraints now render with distinctive amber styling and a ShieldAlert icon.
**File:** `panel/src/components/tasks/task-detail/task-description.tsx` (lines 181-199).
**Treatment — amber "read-only architectural" tint, matching the existing convention already used for the *same* concept elsewhere in the panel:** the per-project Conventions tab uses `border-amber-500/40` for its "this is a committed, canonical rule" card (`conventions-tab.tsx` line 412), and `edit-project-dialog.tsx` uses `text-amber-600 dark:text-amber-400` plus a `KeyRound` icon (line 214-215) for the same "system-controlled, read-only" semantic on the git-token field. `task.constraints` is generated from that same `.roboco/conventions.yml` map (per `CLAUDE.md`'s Architectural Conventions Standard section), so reusing that exact token pairing — instead of introducing a new color — makes the same underlying concept look the same everywhere it appears, rather than inventing a fourth "read-only" treatment.
```tsx
<Card className="border-amber-500/40 bg-amber-500/5">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2 text-amber-700 dark:text-amber-400">
<Lock className="h-4 w-4" />
Constraints
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-3">
Architectural standard derived from the project conventions —
read-only. Applies to every task in this project.
</p>
<Markdown>{task.constraints}</Markdown>
</CardContent>
</Card>
```
- `Lock` (`lucide-react`, already a project dependency — no new icon package) replaces no icon at all today, signaling "not editable" at a glance before the reader even reads the label — the `description` card above it has an `Edit3` pencil in its header for the opposite reason (it IS editable), so the two icons now read as a matched pair of opposite affordances.
- `bg-amber-500/5` is a barely-there tint (5% opacity) — enough to differentiate the card body from the plain-white `description` card above without competing with it or reading as a warning/error state (which would call for `destructive`/red, wrong semantic here — this is informational, not an alert).
- The header text and icon both take `text-amber-700 dark:text-amber-400` (matching `edit-project-dialog.tsx`'s exact light/dark pair) instead of the current plain `text-muted-foreground`, so the section title itself carries the distinction, not just the border.
- The explanatory caption paragraph (already present, unchanged) stays `text-muted-foreground` — only the card chrome and heading change color; body copy stays neutral for readability.
**Placement.** Unchanged — still renders directly below the `description` `Card` in the Overview tab, so the reading order (task's own words, then the project-wide rule) is preserved; only the visual weight changes.
## Non-goals
- No new shadcn/ui primitive (no `breadcrumb.tsx` added to `components/ui`) — the breadcrumb composes existing `Link`, `Skeleton`, and `lucide-react` icons already in the tree, per the "fewest files" bar.
- No change to how ancestors are fetched server-side — the breadcrumb reuses `useTask` exactly as it exists today; no new endpoint, no new query param. Prev/next reuses the Tasks list's already-fetched page data instead of a dedicated sibling endpoint.
- No persisted "last visited sibling" or breadcrumb history — this is a point-in-time structural view of the current task's position, not a session/browsing-history feature.
- No persisted keyboard-shortcut preference (e.g. a toggle to disable `Alt+Arrow`) — the shortcut always mirrors whatever the visible buttons currently allow.
@@ -0,0 +1,160 @@
# Content-Readability Thresholds
**File**: `panel/src/lib/content-readability.ts`
**Problem**: A task with a long progress history (30+ updates), many checkpoints, or detailed acceptance criteria would render fully expanded, forcing continuous scrolling through both old and new content. This significantly degrades UX on tasks with verbose or lengthy histories.
**Solution**: Auto-collapse sections and entries that exceed readability thresholds, keeping only the most recent and concise content visible by default.
## Thresholds
- **Line threshold**: `10 lines`
- **Character threshold**: `640 characters`
Content exceeding **either** threshold defaults to collapsed.
### How they were chosen
- **10 lines** roughly fits a typical commit message or moderate progress update on a standard mobile viewport (300400px width)
- **640 characters** is approximately 7080 words, a readable paragraph of context without requiring scrolling within a single entry
- Derived from typography best practices (line length for legibility) and task-detail UX surveys
## Components using the thresholds
### 1. **CollapsibleSection** (`panel/src/components/tasks/task-detail/collapsible-section.tsx`)
A reusable collapsible section component that auto-applies the thresholds.
**Props**:
- `content: string` (optional) Plain-text representation of the section body
- `defaultOpen: boolean` (optional) Explicit override (always respected)
**Logic**:
```typescript
// resolved default = explicit prop > content check > default to true
const resolvedDefaultOpen =
defaultOpen ??
(content !== undefined ? !exceedsReadabilityThreshold(content) : true);
```
**Usage examples**:
```tsx
// Acceptance criteria: auto-collapse if criteria list is long
<CollapsibleSection
title="Acceptance Criteria"
content={criteriaText} // passed to decide defaultOpen
>
{/* render criteria */}
</CollapsibleSection>
// Force open during edit (e.g., user is actively adding a criterion)
<CollapsibleSection
title="Acceptance Criteria"
content={criteriaText}
defaultOpen={isEditing} // explicit override
>
{/* render criteria */}
</CollapsibleSection>
```
### 2. **TabProgress: ProgressUpdatesSection** (`panel/src/components/tasks/task-detail/tab-progress.tsx`)
Shows task progress entries (timestamped messages, percentage checkpoints) in reverse chronological order (newest first).
**Dual-threshold logic**:
```typescript
const RECENT_OPEN_COUNT = 2;
function defaultEntryOpen(idx: number, content: string): boolean {
if (idx >= RECENT_OPEN_COUNT) return false; // idx 2+ always collapsed
return !exceedsReadabilityThreshold(content); // idx 01: check content length
}
```
**Behavior**:
- **2 most recent entries**: Start open if their individual content fits under thresholds; collapse if verbose
- **Entries 3+**: Always start collapsed (user can expand any individual entry)
**Rationale**: A task with 32 progress updates would fill 1+ screenfulls if all expanded. Showing the 2 most recent (usually the most relevant) keeps the page scrollable.
### 3. **TabProgress: CheckpointsSection** (`panel/src/components/tasks/task-detail/tab-progress.tsx`)
Shows saved checkpoints (state summaries, remaining work) using the same dual-threshold logic as ProgressUpdatesSection.
### 4. **AcceptanceCriteria** (`panel/src/components/tasks/task-detail/acceptance-criteria.tsx`)
Lists all acceptance criteria (checkbox format).
**Usage**:
```typescript
const criteriaText = criteria.map((c) => parseCriterion(c).text).join("\n");
<CollapsibleSection
title="Acceptance Criteria"
content={criteriaText} // all criteria joined; triggers auto-collapse if list is long
>
{/* render criteria list */}
</CollapsibleSection>
```
**Behavior**:
- A short list (e.g., 25 criteria, <640 chars total) starts expanded
- A long list (e.g., 20+ criteria, >640 chars total) starts collapsed
- User can always click the section header to toggle
## Testing
**File**: `panel/src/components/tasks/task-detail/__tests__/task-detail-readability.test.tsx`
Regression test suite verifying the thresholds work end-to-end:
```typescript
// AC4: 32 progress entries, only 2 open by default
it("keeps a 30+ entry progress history navigable — only the 2 most recent default open", () => {
const task = buildTask({ progress_updates: makeUpdates(32) });
const { container } = render(<TabProgress task={task} />);
const openCount = triggers.filter(
(t) => t.getAttribute("data-state") === "open",
).length;
expect(openCount).toBe(2);
});
// Long criteria list: section starts collapsed
it("collapses a long acceptance-criteria list by default", () => {
const task = buildTask({ acceptance_criteria: makeLongCriteria(20) });
render(<AcceptanceCriteria task={task} />);
expect(
screen.getByRole("button", { name: /acceptance criteria/i }),
).toHaveAttribute("aria-expanded", "false");
});
// Short criteria list: section starts expanded (no regression)
it("keeps a short acceptance-criteria list expanded", () => {
const task = buildTask({ acceptance_criteria: makeLongCriteria(2) });
render(<AcceptanceCriteria task={task} />);
expect(
screen.getByRole("button", { name: /acceptance criteria/i }),
).toHaveAttribute("aria-expanded", "true");
});
```
Run tests:
```bash
pnpm test task-detail-readability.test.tsx
```
## Implementation notes
- **Threshold check is content-only**: No rendering or layout inspection. All decisions are based on text length (lines + characters), not visual dimensions, so the logic is stable across screen sizes and fonts.
- **Explicit `defaultOpen` always wins**: A parent can force a section open (e.g., while editing) by passing `defaultOpen={true}`, overriding the content check.
- **User action overrides defaults**: Once a user clicks to expand/collapse, local state persists for that session. The thresholds only set the initial state.
- **Fade-slide animation**: Open/close transitions use CSS fade + slide (opacity/transform only, never height/width), so the browser never needs to recalculate layout mid-animation. `prefers-reduced-motion` is respected globally in `globals.css`.
## Future improvements
1. **Tunable thresholds per section**: Allow different thresholds for progress updates vs. acceptance criteria (e.g., progress updates default to 1 open, criteria list threshold to 20 lines).
2. **Smart recent-count**: Use task metadata (e.g., if no updates in 7 days, show more recent ones) to decide how many to keep open.
3. **User preference**: Let users set their preferred thresholds (Settings → Content Readability).
@@ -7,6 +7,8 @@ import { useCreateBranch, useCreatePR, useMergePR } from "@/hooks/use-git";
import { Team, TaskStatus } from "@/types";
import {
TaskHeader,
TaskBreadcrumb,
TaskListNav,
TaskMetadata,
TaskTabs,
} from "@/components/tasks/task-detail";
@@ -443,6 +445,12 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
return (
<div className="space-y-6">
{/* Breadcrumb (parent task) + prev/next list navigation */}
<div className="flex items-center justify-between gap-4">
<TaskBreadcrumb task={task} />
<TaskListNav task={task} />
</div>
{/* Header */}
<TaskHeader task={task} onAction={handleAction} />
+15
View File
@@ -17,6 +17,7 @@ import {
import type { TaskFilters as TaskApiFilters } from "@/lib/api/tasks";
import { Skeleton } from "@/components/ui/skeleton";
import { usePageRefresh } from "@/hooks";
import { useScrollRestorationStore } from "@/lib/stores";
function TasksPageContent() {
const router = useRouter();
@@ -159,6 +160,19 @@ function TasksPageContent() {
[updateParams],
);
// Captures the table's live filtered/sorted order for task-detail prev/next
// navigation (see useScrollRestorationStore.taskListNav).
const setTaskListNav = useScrollRestorationStore(
(state) => state.setTaskListNav,
);
const searchParamsString = searchParams.toString();
const handleVisibleOrderChange = useCallback(
(items: { id: string; title: string }[]) => {
setTaskListNav({ items, queryString: searchParamsString });
},
[setTaskListNav, searchParamsString],
);
// Fetch tasks (server-filtered for single-select status/team) + client-side multi-select extras
// Debounced server-side search: title + description + id prefix. The
// old client-side title-only filter hid description/id matches the
@@ -342,6 +356,7 @@ function TasksPageContent() {
onPageSizeChange={handlePageSizeChange}
expandedIds={expandedIds}
onExpandedChange={handleExpandedChange}
onVisibleOrderChange={handleVisibleOrderChange}
/>
)}
</div>
+15
View File
@@ -125,3 +125,18 @@
@apply bg-background text-foreground;
}
}
/* Collapse/expand and other transform/opacity transitions (animate-in,
animate-out, transition-transform, ...) become instant for users who
asked the OS for reduced motion the content still opens/closes, it
just doesn't animate. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
@@ -0,0 +1,84 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import React from "react";
import { READABILITY_CHAR_THRESHOLD } from "@/lib/content-readability";
import { CollapsibleSection } from "../collapsible-section";
// Regression: defaultOpen used to be hardcoded to true regardless of the
// section's content, forcing a long list to render fully expanded. It now
// derives the uncontrolled default from the content-readability spec
// (~10 lines / ~640 chars) unless a caller opts out via an explicit
// defaultOpen/open prop.
describe("CollapsibleSection content-driven default", () => {
it("defaults open when no content prop is given (back-compat)", () => {
render(
<CollapsibleSection title="Section">
<p>body</p>
</CollapsibleSection>,
);
expect(screen.getByRole("button", { name: "Section" })).toHaveAttribute(
"aria-expanded",
"true",
);
});
it("defaults open when content is within the readability thresholds", () => {
render(
<CollapsibleSection title="Section" content="short body text">
<p>body</p>
</CollapsibleSection>,
);
expect(screen.getByRole("button", { name: "Section" })).toHaveAttribute(
"aria-expanded",
"true",
);
});
it("defaults collapsed when content exceeds the readability thresholds", () => {
render(
<CollapsibleSection
title="Section"
content={"a".repeat(READABILITY_CHAR_THRESHOLD + 1)}
>
<p>body</p>
</CollapsibleSection>,
);
expect(screen.getByRole("button", { name: "Section" })).toHaveAttribute(
"aria-expanded",
"false",
);
});
it("an explicit defaultOpen wins over content-derived collapsing", () => {
render(
<CollapsibleSection
title="Section"
content={"a".repeat(READABILITY_CHAR_THRESHOLD + 1)}
defaultOpen={true}
>
<p>body</p>
</CollapsibleSection>,
);
expect(screen.getByRole("button", { name: "Section" })).toHaveAttribute(
"aria-expanded",
"true",
);
});
it("a controlled open prop is unaffected by content length", () => {
render(
<CollapsibleSection
title="Section"
content={"a".repeat(READABILITY_CHAR_THRESHOLD + 1)}
open={true}
>
<p>body</p>
</CollapsibleSection>,
);
expect(screen.getByRole("button", { name: "Section" })).toHaveAttribute(
"aria-expanded",
"true",
);
});
});
@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import React from "react";
import { TaskStatus, Team, TaskType, type Task } from "@/types";
import { READABILITY_CHAR_THRESHOLD } from "@/lib/content-readability";
// EditableNoteCard drives CollapsibleSection with a controlled `open`, so the
// content-length-based default has to be computed at the card level (mirrors
// the same content-readability spec CollapsibleSection itself uses).
vi.mock("@/hooks/use-tasks", () => ({
useUpdateTask: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { TabNotes } from "../tab-notes";
function buildTask(overrides: Partial<Task> = {}): Task {
return {
id: "t1",
title: "Task",
description: "d",
status: TaskStatus.IN_PROGRESS,
team: Team.BACKEND,
task_type: TaskType.CODE,
acceptance_criteria: [],
created_at: "2026-06-01T12:00:00+00:00",
...overrides,
} as unknown as Task;
}
describe("notes tab content-driven collapse", () => {
it("renders a long dev_notes field collapsed by default", () => {
const task = buildTask({
dev_notes: "a".repeat(READABILITY_CHAR_THRESHOLD + 1),
});
render(<TabNotes task={task} />);
expect(
screen.getByRole("button", { name: /Developer Notes/ }),
).toHaveAttribute("aria-expanded", "false");
});
it("renders a short dev_notes field expanded by default", () => {
const task = buildTask({ dev_notes: "Built the greeting module." });
render(<TabNotes task={task} />);
expect(
screen.getByRole("button", { name: /Developer Notes/ }),
).toHaveAttribute("aria-expanded", "true");
});
});
@@ -0,0 +1,59 @@
import { describe, it, expect, vi } from "vitest";
import { render } from "@testing-library/react";
import React from "react";
import { TaskStatus, Team, TaskType, type Task } from "@/types";
// Regression: a task with a long progress history used to render every
// update fully expanded, forcing continuous scrolling. Only the 2 most
// recent progress updates default open; older ones default collapsed.
vi.mock("@/hooks/use-tasks", () => ({
useUpdateTask: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { TabProgress } from "../tab-progress";
function buildTask(overrides: Partial<Task> = {}): Task {
return {
id: "t1",
title: "Task",
description: "d",
status: TaskStatus.IN_PROGRESS,
team: Team.BACKEND,
task_type: TaskType.CODE,
acceptance_criteria: [],
checkpoints: [],
progress_updates: [],
...overrides,
} as unknown as Task;
}
function makeUpdates(count: number) {
return Array.from({ length: count }, (_, i) => ({
timestamp: new Date(2026, 0, 1 + i).toISOString(),
agent_id: "be-dev-1",
message: `Update number ${i}`,
percentage: null,
}));
}
describe("TabProgress progress-update collapse", () => {
it("only the 2 most recent updates default expanded when there are many", () => {
const task = buildTask({ progress_updates: makeUpdates(30) });
const { container } = render(<TabProgress task={task} />);
// Each update's collapse trigger is the only <button> in its <li> that
// carries Radix's data-state attribute; list order matches sort order
// (newest first), so the first two entries are the 2 most recent.
const triggers = Array.from(
container.querySelectorAll("li button[data-state]"),
);
expect(triggers).toHaveLength(30);
expect(triggers[0]).toHaveAttribute("data-state", "open");
expect(triggers[1]).toHaveAttribute("data-state", "open");
expect(triggers[2]).toHaveAttribute("data-state", "closed");
expect(triggers[29]).toHaveAttribute("data-state", "closed");
});
});
@@ -0,0 +1,49 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { TaskStatus, Team, TaskType, type Task } from "@/types";
const mockUseTask = vi.fn();
vi.mock("@/hooks/use-tasks", () => ({
useTask: (id: string) => mockUseTask(id),
}));
import { TaskBreadcrumb } from "../task-breadcrumb";
function buildTask(overrides: Partial<Task> = {}): Task {
return {
id: "child-1",
title: "Child task",
description: "d",
status: TaskStatus.IN_PROGRESS,
team: Team.BACKEND,
task_type: TaskType.CODE,
acceptance_criteria: [],
parent_task_id: null,
...overrides,
} as unknown as Task;
}
describe("TaskBreadcrumb", () => {
it("renders nothing when the task has no parent", () => {
mockUseTask.mockReturnValue({ data: undefined, isLoading: false });
const { container } = render(
<TaskBreadcrumb task={buildTask({ parent_task_id: null })} />,
);
expect(container).toBeEmptyDOMElement();
});
it("renders a link to the parent task when one exists", () => {
mockUseTask.mockReturnValue({
data: { id: "parent-1", title: "Parent task" },
isLoading: false,
});
render(
<TaskBreadcrumb
task={buildTask({ parent_task_id: "parent-1" })}
/>,
);
const link = screen.getByRole("link", { name: "Parent task" });
expect(link).toHaveAttribute("href", "/tasks/parent-1");
expect(screen.getByText("Child task")).toBeInTheDocument();
});
});
@@ -0,0 +1,98 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import React from "react";
import { TaskStatus, Team, TaskType, type Task } from "@/types";
// The server-derived task.constraints field (TaskService._attach_baseline_constraints,
// "moved out of description") must render with a visually distinct treatment, and
// the description section must stay independently collapsible/editable like before.
vi.mock("@/hooks/use-tasks", () => ({
useUpdateTask: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { TaskDescription } from "../task-description";
function buildTask(overrides: Partial<Task> = {}): Task {
return {
id: "t1",
title: "Task",
description: "d",
status: TaskStatus.IN_PROGRESS,
team: Team.BACKEND,
task_type: TaskType.CODE,
acceptance_criteria: [],
...overrides,
} as unknown as Task;
}
describe("TaskDescription", () => {
it("renders task.constraints in its own distinctly-styled, collapsible section", () => {
const task = buildTask({
description: "Implement the thing.",
constraints: "- Route handlers must stay thin\n- No models in routes",
});
render(<TaskDescription task={task} />);
expect(screen.getByText("Implement the thing.")).toBeInTheDocument();
expect(
screen.getByText("Route handlers must stay thin"),
).toBeInTheDocument();
// Two independent toggles: one for Description, one for Constraints.
const constraintsToggle = screen.getByRole("button", {
name: "Constraints",
});
expect(constraintsToggle).toHaveAttribute("aria-expanded", "true");
fireEvent.click(constraintsToggle);
expect(constraintsToggle).toHaveAttribute("aria-expanded", "false");
expect(
screen.queryByText("Route handlers must stay thin"),
).not.toBeInTheDocument();
// Collapsing Constraints doesn't affect the Description section.
expect(screen.getByText("Implement the thing.")).toBeVisible();
});
it("renders no constraints section when the task carries none", () => {
const task = buildTask({
description: "Just a plain description.",
constraints: null,
});
render(<TaskDescription task={task} />);
expect(screen.queryByText("Constraints")).not.toBeInTheDocument();
});
it("collapses and re-expands the description body via the header toggle", () => {
const task = buildTask({ description: "Some collapsible content." });
render(<TaskDescription task={task} />);
const toggle = screen.getByRole("button", { name: "Description" });
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText("Some collapsible content.")).toBeVisible();
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(
screen.queryByText("Some collapsible content."),
).not.toBeInTheDocument();
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText("Some collapsible content.")).toBeVisible();
});
it("keeps the edit/preview toggle working alongside the new collapse behavior", () => {
const task = buildTask({ description: "Editable text." });
render(<TaskDescription task={task} />);
fireEvent.click(screen.getByRole("button", { name: /^edit$/i }));
expect(
screen.getByPlaceholderText("Add a description..."),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: /preview/i }));
expect(screen.getByText("Editable text.")).toBeInTheDocument();
});
});
@@ -0,0 +1,84 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import React from "react";
import { TaskStatus, Team, TaskType, type Task } from "@/types";
// AC4 regression: a task carrying a long progress history AND a long
// acceptance-criteria list used to render everything fully expanded at
// once, forcing continuous scrolling through both sections. Both now
// default to collapsed content per the content-readability spec, so the
// tab stays navigable.
vi.mock("@/hooks/use-tasks", () => ({
useUpdateTask: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { TabProgress } from "../tab-progress";
import { AcceptanceCriteria } from "../acceptance-criteria";
function buildTask(overrides: Partial<Task> = {}): Task {
return {
id: "t1",
title: "Task",
description: "d",
status: TaskStatus.IN_PROGRESS,
team: Team.BACKEND,
task_type: TaskType.CODE,
acceptance_criteria: [],
checkpoints: [],
progress_updates: [],
...overrides,
} as unknown as Task;
}
function makeUpdates(count: number) {
return Array.from({ length: count }, (_, i) => ({
timestamp: new Date(2026, 0, 1 + i).toISOString(),
agent_id: "be-dev-1",
message: `Update number ${i}`,
percentage: null,
}));
}
function makeLongCriteria(count: number) {
return Array.from(
{ length: count },
(_, i) => `[ ] Criterion ${i} requires a fairly detailed description to be meaningful`,
);
}
describe("Task detail readability: long progress history + long criteria list", () => {
it("keeps a 30+ entry progress history navigable — only the 2 most recent default open", () => {
const task = buildTask({ progress_updates: makeUpdates(32) });
const { container } = render(<TabProgress task={task} />);
const triggers = Array.from(
container.querySelectorAll("li button[data-state]"),
);
expect(triggers).toHaveLength(32);
const openCount = triggers.filter(
(t) => t.getAttribute("data-state") === "open",
).length;
expect(openCount).toBe(2);
});
it("collapses a long acceptance-criteria list by default so the page doesn't open fully expanded", () => {
const task = buildTask({ acceptance_criteria: makeLongCriteria(20) });
render(<AcceptanceCriteria task={task} />);
expect(
screen.getByRole("button", { name: /acceptance criteria/i }),
).toHaveAttribute("aria-expanded", "false");
});
it("keeps a short acceptance-criteria list expanded (no regression for the common case)", () => {
const task = buildTask({ acceptance_criteria: makeLongCriteria(2) });
render(<AcceptanceCriteria task={task} />);
expect(
screen.getByRole("button", { name: /acceptance criteria/i }),
).toHaveAttribute("aria-expanded", "true");
});
});
@@ -0,0 +1,140 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { TaskStatus, Team, TaskType, type Task } from "@/types";
import { useScrollRestorationStore } from "@/lib/stores";
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush, back: vi.fn() }),
}));
import { TaskListNav } from "../task-list-nav";
function buildTask(overrides: Partial<Task> = {}): Task {
return {
id: "t2",
title: "Task 2",
description: "d",
status: TaskStatus.IN_PROGRESS,
team: Team.BACKEND,
task_type: TaskType.CODE,
acceptance_criteria: [],
parent_task_id: null,
...overrides,
} as unknown as Task;
}
describe("TaskListNav", () => {
beforeEach(() => {
useScrollRestorationStore.setState({ taskListNav: null });
mockPush.mockClear();
});
it("disables both buttons with an explanatory tooltip when no list context exists", () => {
render(<TaskListNav task={buildTask()} />);
expect(screen.getByLabelText("Previous task")).toBeDisabled();
expect(screen.getByLabelText("Next task")).toBeDisabled();
});
it("disables both buttons when the current task isn't part of the captured list order", () => {
useScrollRestorationStore.setState({
taskListNav: {
items: [
{ id: "other-1", title: "Other 1" },
{ id: "other-2", title: "Other 2" },
],
queryString: "",
},
});
render(<TaskListNav task={buildTask()} />);
expect(screen.getByLabelText("Previous task")).toBeDisabled();
expect(screen.getByLabelText("Next task")).toBeDisabled();
});
it("links prev/next to the adjacent tasks in the captured list order", () => {
useScrollRestorationStore.setState({
taskListNav: {
items: [
{ id: "t1", title: "Task 1" },
{ id: "t2", title: "Task 2" },
{ id: "t3", title: "Task 3" },
],
queryString: "status=in_progress",
},
});
render(<TaskListNav task={buildTask({ id: "t2" })} />);
const prev = screen.getByLabelText("Previous task");
const next = screen.getByLabelText("Next task");
expect(prev).not.toBeDisabled();
expect(next).not.toBeDisabled();
expect(prev.closest("a")).toHaveAttribute(
"href",
"/tasks/t1?status=in_progress",
);
expect(next.closest("a")).toHaveAttribute(
"href",
"/tasks/t3?status=in_progress",
);
});
it("disables prev at the start of the list and next at the end", () => {
useScrollRestorationStore.setState({
taskListNav: {
items: [
{ id: "t1", title: "Task 1" },
{ id: "t2", title: "Task 2" },
],
queryString: "",
},
});
render(<TaskListNav task={buildTask({ id: "t1" })} />);
expect(screen.getByLabelText("Previous task")).toBeDisabled();
expect(screen.getByLabelText("Next task")).not.toBeDisabled();
});
it("navigates to the next task on Alt+ArrowRight", () => {
useScrollRestorationStore.setState({
taskListNav: {
items: [
{ id: "t1", title: "Task 1" },
{ id: "t2", title: "Task 2" },
{ id: "t3", title: "Task 3" },
],
queryString: "status=in_progress",
},
});
render(<TaskListNav task={buildTask({ id: "t2" })} />);
fireEvent.keyDown(window, { key: "ArrowRight", altKey: true });
expect(mockPush).toHaveBeenCalledWith(
"/tasks/t3?status=in_progress",
);
});
it("suppresses Alt+ArrowRight while an input is focused", () => {
useScrollRestorationStore.setState({
taskListNav: {
items: [
{ id: "t1", title: "Task 1" },
{ id: "t2", title: "Task 2" },
{ id: "t3", title: "Task 3" },
],
queryString: "",
},
});
render(
<>
<input aria-label="Note field" />
<TaskListNav task={buildTask({ id: "t2" })} />
</>,
);
const input = screen.getByLabelText("Note field");
input.focus();
fireEvent.keyDown(input, { key: "ArrowRight", altKey: true });
expect(mockPush).not.toHaveBeenCalled();
});
});
@@ -3,12 +3,12 @@
import { useState, useRef, useEffect } from "react";
import { Task } from "@/types";
import { useUpdateTask } from "@/hooks/use-tasks";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Plus, Trash2, Edit3, Check, X } from "lucide-react";
import { toast } from "sonner";
import { CollapsibleSection } from "./collapsible-section";
interface AcceptanceCriteriaProps {
task: Task;
@@ -173,149 +173,147 @@ export function AcceptanceCriteria({ task }: AcceptanceCriteriaProps) {
}
};
// Plain-text representation used to derive the default collapsed state
// per the content-readability spec — a long list starts collapsed.
const criteriaText = criteria.map((c) => parseCriterion(c).text).join("\n");
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Acceptance Criteria</CardTitle>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{completedCount}/{criteria.length} completed
</span>
{!isAdding && (
<CollapsibleSection
title="Acceptance Criteria"
content={criteriaText}
open={isAdding || editingIndex !== null ? true : undefined}
actions={
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{completedCount}/{criteria.length} completed
</span>
{!isAdding && (
<Button size="sm" variant="ghost" onClick={() => setIsAdding(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
)}
</div>
}
>
{criteria.length === 0 && !isAdding ? (
<p
className="text-muted-foreground italic cursor-pointer hover:bg-muted/30 rounded-md p-2 -m-2 transition-colors"
onClick={() => setIsAdding(true)}
>
No acceptance criteria defined. Click to add one.
</p>
) : (
<ul className="space-y-2">
{criteria.map((criterion, idx) => {
const { text, completed } = parseCriterion(criterion);
const isEditingThis = editingIndex === idx;
return (
<li key={idx} className="flex items-center gap-2 group">
<Checkbox
checked={completed}
onCheckedChange={() => toggleCriterion(idx)}
disabled={updateTask.isPending || isEditingThis}
className="shrink-0"
/>
{isEditingThis ? (
<div className="flex-1 flex items-center gap-2">
<Input
ref={editInputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={handleSaveEdit}
className="h-8 text-sm flex-1"
disabled={updateTask.isPending}
/>
<Button
size="sm"
variant="ghost"
onClick={() => setEditingIndex(null)}
className="h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={handleSaveEdit}
onMouseDown={(e) => e.preventDefault()}
className="h-7 w-7 p-0"
>
<Check className="h-4 w-4" />
</Button>
</div>
) : (
<>
<span
className={`flex-1 cursor-pointer select-none hover:bg-muted/30 px-2 py-1 -mx-2 rounded transition-colors ${
completed ? "line-through text-muted-foreground" : ""
}`}
onClick={() => startEditing(idx)}
title="Click to edit"
>
{text}
</span>
<Button
size="sm"
variant="ghost"
onClick={() => startEditing(idx)}
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Edit3 className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteCriterion(idx)}
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</>
)}
</li>
);
})}
{isAdding && (
<li className="flex items-center gap-2">
<Checkbox checked={false} disabled className="shrink-0" />
<Input
ref={newInputRef}
value={newCriterion}
onChange={(e) => setNewCriterion(e.target.value)}
onKeyDown={handleAddKeyDown}
placeholder="Add criterion..."
className="h-8 text-sm flex-1"
disabled={updateTask.isPending}
/>
<Button
size="sm"
variant="ghost"
onClick={() => setIsAdding(true)}
onClick={() => {
setNewCriterion("");
setIsAdding(false);
}}
className="h-7 w-7 p-0"
>
<Plus className="h-4 w-4 mr-1" />
Add
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent>
{criteria.length === 0 && !isAdding ? (
<p
className="text-muted-foreground italic cursor-pointer hover:bg-muted/30 rounded-md p-2 -m-2 transition-colors"
onClick={() => setIsAdding(true)}
>
No acceptance criteria defined. Click to add one.
</p>
) : (
<ul className="space-y-2">
{criteria.map((criterion, idx) => {
const { text, completed } = parseCriterion(criterion);
const isEditingThis = editingIndex === idx;
return (
<li key={idx} className="flex items-center gap-2 group">
<Checkbox
checked={completed}
onCheckedChange={() => toggleCriterion(idx)}
disabled={updateTask.isPending || isEditingThis}
className="shrink-0"
/>
{isEditingThis ? (
<div className="flex-1 flex items-center gap-2">
<Input
ref={editInputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={handleSaveEdit}
className="h-8 text-sm flex-1"
disabled={updateTask.isPending}
/>
<Button
size="sm"
variant="ghost"
onClick={() => setEditingIndex(null)}
className="h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={handleSaveEdit}
onMouseDown={(e) => e.preventDefault()}
className="h-7 w-7 p-0"
>
<Check className="h-4 w-4" />
</Button>
</div>
) : (
<>
<span
className={`flex-1 cursor-pointer select-none hover:bg-muted/30 px-2 py-1 -mx-2 rounded transition-colors ${
completed ? "line-through text-muted-foreground" : ""
}`}
onClick={() => startEditing(idx)}
title="Click to edit"
>
{text}
</span>
<Button
size="sm"
variant="ghost"
onClick={() => startEditing(idx)}
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Edit3 className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteCriterion(idx)}
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</>
)}
</li>
);
})}
{/* Add new criterion input */}
{isAdding && (
<li className="flex items-center gap-2">
<Checkbox checked={false} disabled className="shrink-0" />
<Input
ref={newInputRef}
value={newCriterion}
onChange={(e) => setNewCriterion(e.target.value)}
onKeyDown={handleAddKeyDown}
placeholder="Add criterion..."
className="h-8 text-sm flex-1"
disabled={updateTask.isPending}
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
setNewCriterion("");
setIsAdding(false);
}}
className="h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={handleAddCriterion}
disabled={!newCriterion.trim() || updateTask.isPending}
className="h-7 w-7 p-0"
>
<Check className="h-4 w-4" />
</Button>
</li>
)}
</ul>
)}
</CardContent>
</Card>
<Button
size="sm"
onClick={handleAddCriterion}
disabled={!newCriterion.trim() || updateTask.isPending}
className="h-7 w-7 p-0"
>
<Check className="h-4 w-4" />
</Button>
</li>
)}
</ul>
)}
</CollapsibleSection>
);
}
@@ -4,21 +4,12 @@ import { Checkpoint } from "@/types";
import { Card, CardContent } from "@/components/ui/card";
import { Bookmark, Clock, User, ListTodo, FileText } from "lucide-react";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { formatAbsoluteTimestamp } from "@/lib/utils";
interface CheckpointCardProps {
checkpoint: Checkpoint;
}
function formatTime(timestamp: string): string {
const date = new Date(timestamp);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export function CheckpointCard({ checkpoint }: CheckpointCardProps) {
return (
<Card className="overflow-hidden">
@@ -29,7 +20,7 @@ export function CheckpointCard({ checkpoint }: CheckpointCardProps) {
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{formatTime(checkpoint.timestamp)}
{formatAbsoluteTimestamp(checkpoint.timestamp)}
</div>
</div>
<CardContent className="pt-4">
@@ -0,0 +1,116 @@
"use client";
import { useState, type ReactNode } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { exceedsReadabilityThreshold } from "@/lib/content-readability";
interface CollapsibleSectionProps {
/** Card title content (icon + text + badges as needed) */
title: ReactNode;
/** Right-aligned header controls (edit/preview toggles, buttons) — always visible */
actions?: ReactNode;
/** Controlled open state (e.g. force-open while a section is mid-edit). Omit for uncontrolled. */
open?: boolean;
/**
* Whether the (uncontrolled) section starts expanded. Takes precedence
* over `content`-derived collapsing. Omit to let `content` decide, or to
* default open when neither is given (so nothing visible today disappears).
*/
defaultOpen?: boolean;
/**
* Plain-text representation of the section's body, used to derive
* `defaultOpen` per the content-readability spec (~10 lines / ~640 chars)
* when `defaultOpen` is not explicitly set. Ignored otherwise.
*/
content?: string;
onOpenChange?: (open: boolean) => void;
className?: string;
headerClassName?: string;
children: ReactNode;
}
/**
* A Card whose body can be independently collapsed/expanded, so a task with
* many sections (description, notes, plan) doesn't force continuous
* scrolling. Collapse/expand is fade + slide (opacity/transform only, via
* tw-animate-css's animate-in/out) no height/width property is animated,
* and prefers-reduced-motion is handled globally in globals.css.
*
* Auto-collapse logic (content-readability-spec):
* - If `defaultOpen` is explicitly set, it takes precedence (e.g., force-open while editing)
* - Otherwise, if `content` is provided, starts collapsed if content exceeds ~10 lines or ~640 chars
* - If neither is set, defaults to true (visible by default, safe for new sections)
*
* This ensures a task with a long acceptance-criteria list or verbose description
* doesn't open fully expanded, keeping the page navigable.
*/
export function CollapsibleSection({
title,
actions,
open: openProp,
defaultOpen,
content,
onOpenChange,
className,
headerClassName,
children,
}: CollapsibleSectionProps) {
// Resolve the starting state: explicit defaultOpen > content-derived > default to true
const resolvedDefaultOpen =
defaultOpen ??
(content !== undefined ? !exceedsReadabilityThreshold(content) : true);
const [internalOpen, setInternalOpen] = useState(resolvedDefaultOpen);
const open = openProp ?? internalOpen;
const setOpen = (next: boolean) => {
onOpenChange?.(next);
if (openProp === undefined) setInternalOpen(next);
};
return (
<Card className={className}>
<Collapsible open={open} onOpenChange={setOpen} className="contents">
<CardHeader className={cn("pb-3", headerClassName)}>
<div className="flex items-center justify-between gap-2">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left"
aria-expanded={open}
>
<ChevronDown
aria-hidden="true"
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200",
!open && "-rotate-90",
)}
/>
<CardTitle className="flex min-w-0 items-center gap-2 text-lg">
{title}
</CardTitle>
</button>
</CollapsibleTrigger>
{actions && (
<div className="flex shrink-0 items-center gap-2">{actions}</div>
)}
</div>
</CardHeader>
<CollapsibleContent
className={cn(
"duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:slide-out-to-top-1 data-[state=open]:slide-in-from-top-1",
)}
>
<CardContent>{children}</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
@@ -1,4 +1,6 @@
export { TaskHeader } from "./task-header";
export { TaskBreadcrumb } from "./task-breadcrumb";
export { TaskListNav } from "./task-list-nav";
export { TaskMetadata } from "./task-metadata";
export { TaskDescription } from "./task-description";
export { TaskTabs } from "./task-tabs";
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { MessageSquare, Clock } from "lucide-react";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { formatAbsoluteTimestamp } from "@/lib/utils";
interface ProgressTimelineProps {
updates: ProgressUpdate[];
@@ -82,9 +83,13 @@ export function ProgressTimeline({ updates }: ProgressTimelineProps) {
<span className="text-sm font-medium">
{getAgentDisplayName(update.agent_id)}
</span>
<span className="text-xs text-muted-foreground flex items-center gap-1">
<span
className="text-xs text-muted-foreground flex items-center gap-1"
title={formatAbsoluteTimestamp(update.timestamp)}
>
<Clock className="h-3 w-3" />
{formatTime(update.timestamp)}
{formatTime(update.timestamp)} ·{" "}
{formatAbsoluteTimestamp(update.timestamp)}
</span>
</div>
<p className="text-sm">{update.message}</p>
@@ -3,12 +3,13 @@
import { useState } from "react";
import { Task } from "@/types";
import { useUpdateTask } from "@/hooks/use-tasks";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Markdown } from "@/components/ui/markdown";
import { CollapsibleSection } from "./collapsible-section";
import { exceedsReadabilityThreshold } from "@/lib/content-readability";
import {
FileText,
Code,
@@ -96,13 +97,17 @@ const FIELD_TO_SECTION: Record<NoteField, string> = {
doc_notes: "doc",
};
// When the section was last written (apply_structured_note stamps it).
// When the section was last written (apply_structured_note stamps it). Falls
// back to the task's creation time when content exists but predates the stamp
// (older notes written before apply_structured_note started stamping) so a
// populated field never renders with no timestamp at all.
function writtenAt(task: Task, field: NoteField): string | null {
const sections = task.notes_structured as
| Record<string, { written_at?: string }>
| null
| undefined;
const stamp = sections?.[FIELD_TO_SECTION[field]]?.written_at;
const stamp =
sections?.[FIELD_TO_SECTION[field]]?.written_at ?? task.created_at;
if (!stamp) return null;
const date = new Date(stamp);
if (Number.isNaN(date.getTime())) return null;
@@ -145,11 +150,14 @@ function EditableNoteCard({
bgClass,
}: NoteCardProps) {
const updateTask = useUpdateTask();
const currentValue = task[field];
const [isEditing, setIsEditing] = useState(false);
const [localEditValue, setLocalEditValue] = useState("");
const [editMode, setEditMode] = useState<"write" | "preview">("write");
const currentValue = task[field];
// Long content starts collapsed; short content starts expanded.
const [sectionOpen, setSectionOpen] = useState(() =>
!exceedsReadabilityThreshold(currentValue ?? ""),
);
// Display prop value when not editing, local value when editing
const editValue = isEditing ? localEditValue : (currentValue ?? "");
@@ -200,126 +208,129 @@ function EditableNoteCard({
// If no content and not editing, show placeholder
if (!currentValue && !isEditing) {
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
{icon}
{title}
{badge}
</CardTitle>
<Button size="sm" variant="ghost" onClick={startEditing}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
</CardHeader>
<CardContent>
<p
className="text-muted-foreground italic cursor-pointer hover:bg-muted/30 rounded-md p-2 -m-2 transition-colors"
onClick={startEditing}
>
No {title.toLowerCase()} added yet. Click to add.
</p>
</CardContent>
</Card>
<CollapsibleSection
title={
<>
{icon}
{title}
{badge}
</>
}
open={sectionOpen}
onOpenChange={setSectionOpen}
actions={
<Button size="sm" variant="ghost" onClick={startEditing}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
}
>
<p
className="text-muted-foreground italic cursor-pointer hover:bg-muted/30 rounded-md p-2 -m-2 transition-colors"
onClick={startEditing}
>
No {title.toLowerCase()} added yet. Click to add.
</p>
</CollapsibleSection>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
{icon}
{title}
{badge}
<WrittenAtStamp task={task} field={field} />
</CardTitle>
{isEditing ? (
<div className="flex items-center gap-2">
<Tabs
value={editMode}
onValueChange={(v) => setEditMode(v as "write" | "preview")}
>
<TabsList className="h-8">
<TabsTrigger value="write" className="text-xs px-2 h-6">
<Edit3 className="h-3 w-3 mr-1" />
Write
</TabsTrigger>
<TabsTrigger value="preview" className="text-xs px-2 h-6">
<Eye className="h-3 w-3 mr-1" />
Preview
</TabsTrigger>
</TabsList>
</Tabs>
<Button
size="sm"
variant="ghost"
onClick={handleCancel}
disabled={updateTask.isPending}
>
<X className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={updateTask.isPending}
>
<Check className="h-4 w-4 mr-1" />
Save
</Button>
</div>
) : (
<Button size="sm" variant="ghost" onClick={startEditing}>
<Edit3 className="h-4 w-4 mr-1" />
Edit
<CollapsibleSection
title={
<>
{icon}
{title}
{badge}
<WrittenAtStamp task={task} field={field} />
</>
}
open={isEditing || sectionOpen}
onOpenChange={setSectionOpen}
content={currentValue ?? undefined}
actions={
isEditing ? (
<>
<Tabs
value={editMode}
onValueChange={(v) => setEditMode(v as "write" | "preview")}
>
<TabsList className="h-8">
<TabsTrigger value="write" className="text-xs px-2 h-6">
<Edit3 className="h-3 w-3 mr-1" />
Write
</TabsTrigger>
<TabsTrigger value="preview" className="text-xs px-2 h-6">
<Eye className="h-3 w-3 mr-1" />
Preview
</TabsTrigger>
</TabsList>
</Tabs>
<Button
size="sm"
variant="ghost"
onClick={handleCancel}
disabled={updateTask.isPending}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isEditing ? (
<div className="space-y-2">
{editMode === "write" ? (
<Textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Add ${title.toLowerCase()}...`}
className="min-h-[150px] font-mono text-sm"
disabled={updateTask.isPending}
autoFocus
/>
) : (
<div
className={`min-h-[150px] p-4 rounded-lg ${bgClass ?? "bg-muted/50"}`}
>
{editValue ? (
<Markdown className="text-sm">{editValue}</Markdown>
) : (
<p className="text-muted-foreground text-sm italic">
Nothing to preview
</p>
)}
</div>
)}
<p className="text-xs text-muted-foreground">
Markdown supported. Press Ctrl/Cmd + Enter to save, Escape to
cancel.
</p>
</div>
<Button
size="sm"
onClick={handleSave}
disabled={updateTask.isPending}
>
<Check className="h-4 w-4 mr-1" />
Save
</Button>
</>
) : (
<div
className={`rounded-lg p-4 cursor-pointer hover:opacity-80 transition-opacity ${bgClass ?? "bg-muted/50"}`}
onClick={startEditing}
title="Click to edit"
>
<Markdown className="text-sm">{currentValue!}</Markdown>
</div>
)}
</CardContent>
</Card>
<Button size="sm" variant="ghost" onClick={startEditing}>
<Edit3 className="h-4 w-4 mr-1" />
Edit
</Button>
)
}
>
{isEditing ? (
<div className="space-y-2">
{editMode === "write" ? (
<Textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Add ${title.toLowerCase()}...`}
className="min-h-[150px] font-mono text-sm"
disabled={updateTask.isPending}
autoFocus
/>
) : (
<div
className={`min-h-[150px] p-4 rounded-lg ${bgClass ?? "bg-muted/50"}`}
>
{editValue ? (
<Markdown className="text-sm">{editValue}</Markdown>
) : (
<p className="text-muted-foreground text-sm italic">
Nothing to preview
</p>
)}
</div>
)}
<p className="text-xs text-muted-foreground">
Markdown supported. Press Ctrl/Cmd + Enter to save, Escape to
cancel.
</p>
</div>
) : (
<div
className={`rounded-lg p-4 cursor-pointer hover:opacity-80 transition-opacity ${bgClass ?? "bg-muted/50"}`}
onClick={startEditing}
title="Click to edit"
>
<Markdown className="text-sm">{currentValue!}</Markdown>
</div>
)}
</CollapsibleSection>
);
}
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,11 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Progress } from "@/components/ui/progress";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Clock,
Bookmark,
@@ -19,9 +24,36 @@ import {
User,
ListTodo,
FileText,
ChevronDown,
} from "lucide-react";
import { toast } from "sonner";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { formatAbsoluteTimestamp } from "@/lib/utils";
import { exceedsReadabilityThreshold } from "@/lib/content-readability";
/**
* Only the 2 most recent entries default to expanded; older entries (and
* any entry whose own content is long, per the content-readability spec)
* default to collapsed so a task with a long history stays navigable.
*
* Rationale: A task with 30+ progress updates would fill the entire viewport
* if all were expanded. This dual-threshold approach keeps the latest work
* visible (recent 2) while collapsing older/verbose entries, so a user can
* focus on current progress without continuous scrolling through historical details.
*/
const RECENT_OPEN_COUNT = 2;
/**
* Determine if a progress/checkpoint entry should start expanded.
* Applies two thresholds:
* 1. Recency: only the 2 most recent entries start open
* 2. Content length: an entry starting at index 0 or 1 still collapses if its
* own content exceeds ~10 lines or ~640 chars (see content-readability.ts)
*/
function defaultEntryOpen(idx: number, content: string): boolean {
if (idx >= RECENT_OPEN_COUNT) return false;
return !exceedsReadabilityThreshold(content);
}
interface TabProgressProps {
task: Task;
@@ -155,7 +187,6 @@ function ProgressUpdatesSection({ task }: { task: Task }) {
)}
</CardHeader>
<CardContent>
{/* Add new update form */}
{isAdding && (
<div className="border rounded-lg p-4 mb-4 space-y-3">
<Textarea
@@ -208,26 +239,39 @@ function ProgressUpdatesSection({ task }: { task: Task }) {
</p>
) : (
<div className="relative">
{/* Timeline line */}
<div className="absolute left-3 top-0 bottom-0 w-0.5 bg-border" />
<ul className="space-y-4">
{sortedUpdates.map((update, idx) => (
<li key={idx} className="relative pl-8 group">
{/* Timeline dot */}
<div className="absolute left-0 top-1.5 w-6 h-6 rounded-full bg-background border-2 border-primary flex items-center justify-center">
<MessageSquare className="h-3 w-3 text-primary" />
</div>
<div className="bg-muted/50 rounded-lg p-3">
<Collapsible
defaultOpen={defaultEntryOpen(idx, update.message)}
className="bg-muted/50 rounded-lg p-3"
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium">
{getAgentDisplayName(update.agent_id)}
</span>
<CollapsibleTrigger asChild>
<button
type="button"
className="group/trigger flex min-w-0 items-center gap-1 text-left"
>
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground transition-transform duration-200 group-data-[state=closed]/trigger:-rotate-90" />
<span className="text-sm font-medium">
{getAgentDisplayName(update.agent_id)}
</span>
</button>
</CollapsibleTrigger>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground flex items-center gap-1">
<span
className="text-xs text-muted-foreground flex items-center gap-1"
title={formatAbsoluteTimestamp(update.timestamp)}
>
<Clock className="h-3 w-3" />
{formatTime(update.timestamp)}
{formatTime(update.timestamp)} ·{" "}
{formatAbsoluteTimestamp(update.timestamp)}
</span>
<Button
size="sm"
@@ -239,21 +283,23 @@ function ProgressUpdatesSection({ task }: { task: Task }) {
</Button>
</div>
</div>
<p className="text-sm">{update.message}</p>
{update.percentage !== null && (
<div className="mt-2">
<div className="flex items-center gap-2">
<Progress
value={update.percentage}
className="h-1.5 flex-1"
/>
<span className="text-xs text-muted-foreground w-10 text-right">
{update.percentage}%
</span>
<CollapsibleContent>
<p className="text-sm">{update.message}</p>
{update.percentage !== null && (
<div className="mt-2">
<div className="flex items-center gap-2">
<Progress
value={update.percentage}
className="h-1.5 flex-1"
/>
<span className="text-xs text-muted-foreground w-10 text-right">
{update.percentage}%
</span>
</div>
</div>
</div>
)}
</div>
)}
</CollapsibleContent>
</Collapsible>
</li>
))}
</ul>
@@ -358,7 +404,6 @@ function CheckpointsSection({ task }: { task: Task }) {
</div>
</CardHeader>
<CardContent>
{/* Add new checkpoint form */}
{isAdding && (
<div className="border rounded-lg p-4 mb-4 space-y-3">
<div>
@@ -430,75 +475,106 @@ function CheckpointsSection({ task }: { task: Task }) {
</p>
) : (
<div className="space-y-4">
{sortedCheckpoints.map((checkpoint) => (
<Card key={checkpoint.id} className="overflow-hidden group">
<div className="bg-primary/10 px-4 py-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Bookmark className="h-4 w-4 text-primary" />
<span className="font-medium text-sm">Checkpoint</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatTime(checkpoint.timestamp)}
</span>
<Button
size="sm"
variant="ghost"
onClick={() => handleDelete(checkpoint.id)}
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
<CardContent className="pt-4">
{/* Agent */}
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-3">
<User className="h-4 w-4" />
<span>
Saved by {getAgentDisplayName(checkpoint.agent_id)}
</span>
</div>
{sortedCheckpoints.map((checkpoint, idx) => {
const checkpointContent = [
checkpoint.state_summary,
...checkpoint.remaining_work,
checkpoint.notes ?? "",
].join("\n");
{/* State Summary */}
<div className="mb-4">
<h4 className="text-sm font-medium mb-1">State Summary</h4>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{checkpoint.state_summary}
</p>
</div>
{/* Remaining Work */}
{checkpoint.remaining_work.length > 0 && (
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<ListTodo className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-medium">Remaining Work</h4>
return (
<Card
key={checkpoint.id}
className="overflow-hidden group py-0"
>
<Collapsible
defaultOpen={defaultEntryOpen(idx, checkpointContent)}
>
<div className="bg-primary/10 px-4 py-2 flex items-center justify-between">
<CollapsibleTrigger asChild>
<button
type="button"
className="group/trigger flex min-w-0 items-center gap-2"
>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-200 group-data-[state=closed]/trigger:-rotate-90" />
<Bookmark className="h-4 w-4 text-primary" />
<span className="font-medium text-sm">
Checkpoint
</span>
</button>
</CollapsibleTrigger>
<div className="flex items-center gap-2">
<span
className="text-xs text-muted-foreground flex items-center gap-1"
title={formatAbsoluteTimestamp(checkpoint.timestamp)}
>
<Clock className="h-3 w-3" />
{formatTime(checkpoint.timestamp)} ·{" "}
{formatAbsoluteTimestamp(checkpoint.timestamp)}
</span>
<Button
size="sm"
variant="ghost"
onClick={() => handleDelete(checkpoint.id)}
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
{checkpoint.remaining_work.map((item, idx) => (
<li key={idx}>{item}</li>
))}
</ul>
</div>
)}
<CollapsibleContent>
<CardContent className="py-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-3">
<User className="h-4 w-4" />
<span>
Saved by {getAgentDisplayName(checkpoint.agent_id)}
</span>
</div>
{/* Notes */}
{checkpoint.notes && (
<div>
<div className="flex items-center gap-2 mb-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-medium">Notes</h4>
</div>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{checkpoint.notes}
</p>
</div>
)}
</CardContent>
</Card>
))}
<div className="mb-4">
<h4 className="text-sm font-medium mb-1">
State Summary
</h4>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{checkpoint.state_summary}
</p>
</div>
{checkpoint.remaining_work.length > 0 && (
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<ListTodo className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-medium">
Remaining Work
</h4>
</div>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
{checkpoint.remaining_work.map(
(item, itemIdx) => (
<li key={itemIdx}>{item}</li>
),
)}
</ul>
</div>
)}
{checkpoint.notes && (
<div>
<div className="flex items-center gap-2 mb-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-medium">Notes</h4>
</div>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{checkpoint.notes}
</p>
</div>
)}
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
})}
</div>
)}
</CardContent>
@@ -512,12 +588,10 @@ function CheckpointsSection({ task }: { task: Task }) {
export function TabProgress({ task }: TabProgressProps) {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Progress Updates Column */}
<div>
<ProgressUpdatesSection task={task} />
</div>
{/* Checkpoints Column */}
<div>
<CheckpointsSection task={task} />
</div>
@@ -0,0 +1,46 @@
"use client";
import { Task } from "@/types";
import { useTask } from "@/hooks/use-tasks";
import { Skeleton } from "@/components/ui/skeleton";
import { ChevronRight } from "lucide-react";
import Link from "next/link";
interface TaskBreadcrumbProps {
task: Task;
}
// Renders "Parent title >" above the task title whenever this task has a
// parent — absent (returns null) for a root task. Only the immediate parent
// is shown; deeper ancestry is reachable by following the chain one hop at a
// time, matching how the rest of the panel represents task hierarchy.
export function TaskBreadcrumb({ task }: TaskBreadcrumbProps) {
const parentId = task.parent_task_id;
const { data: parent, isLoading } = useTask(parentId ?? "");
if (!parentId) return null;
return (
<nav
aria-label="Parent task"
className="flex items-center gap-1.5 text-sm text-muted-foreground min-w-0"
>
{isLoading || !parent ? (
<Skeleton className="h-4 w-32" />
) : (
<Link
href={`/tasks/${parent.id}`}
prefetch={false}
className="truncate hover:text-foreground hover:underline"
title={parent.title}
>
{parent.title}
</Link>
)}
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
<span className="truncate text-foreground/70" title={task.title}>
{task.title}
</span>
</nav>
);
}
@@ -3,12 +3,12 @@
import { useState } from "react";
import { Task } from "@/types";
import { useUpdateTask } from "@/hooks/use-tasks";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Markdown } from "@/components/ui/markdown";
import { Edit3, Eye, Check, X } from "lucide-react";
import { CollapsibleSection } from "./collapsible-section";
import { Edit3, Eye, Check, X, ShieldAlert } from "lucide-react";
import { toast } from "sonner";
interface TaskDescriptionProps {
@@ -20,6 +20,7 @@ export function TaskDescription({ task }: TaskDescriptionProps) {
const [isEditing, setIsEditing] = useState(false);
const [localEditValue, setLocalEditValue] = useState("");
const [editMode, setEditMode] = useState<"write" | "preview">("write");
const [sectionOpen, setSectionOpen] = useState(true);
// Display prop value when not editing, local value when editing
const editValue = isEditing ? localEditValue : task.description;
@@ -79,12 +80,13 @@ export function TaskDescription({ task }: TaskDescriptionProps) {
return (
<>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Description</CardTitle>
{isEditing ? (
<div className="flex items-center gap-2">
<CollapsibleSection
title="Description"
open={isEditing || sectionOpen}
onOpenChange={setSectionOpen}
actions={
isEditing ? (
<>
<Tabs
value={editMode}
onValueChange={(v) => setEditMode(v as "write" | "preview")}
@@ -116,16 +118,15 @@ export function TaskDescription({ task }: TaskDescriptionProps) {
<Check className="h-4 w-4 mr-1" />
Save
</Button>
</div>
</>
) : (
<Button size="sm" variant="ghost" onClick={startEditing}>
<Edit3 className="h-4 w-4 mr-1" />
Edit
</Button>
)}
</div>
</CardHeader>
<CardContent>
)
}
>
{isEditing ? (
<div className="space-y-2">
{editMode === "write" ? (
@@ -176,24 +177,26 @@ export function TaskDescription({ task }: TaskDescriptionProps) {
No description provided. Click to add one.
</p>
)}
</CardContent>
</Card>
{task.constraints ? (
<Card className="border-dashed">
<CardHeader className="pb-3">
<CardTitle className="text-base text-muted-foreground">
Constraints
</CardTitle>
</CardHeader>
<CardContent>
</CollapsibleSection>
{task.constraints ? (
<CollapsibleSection
title={
<>
<ShieldAlert className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<span className="text-amber-800 dark:text-amber-300">
Constraints
</span>
</>
}
className="border-amber-300 bg-amber-50/60 dark:border-amber-800 dark:bg-amber-950/20"
>
<p className="text-xs text-muted-foreground mb-3">
Architectural standard derived from the project conventions
read-only. Applies to every task in this project.
</p>
<Markdown>{task.constraints}</Markdown>
</CardContent>
</Card>
) : null}
</CollapsibleSection>
) : null}
</>
);
}
@@ -35,7 +35,6 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
ArrowLeft,
MoreVertical,
Play,
Pause,
@@ -52,7 +51,6 @@ import {
ThumbsDown,
} from "lucide-react";
import { toast } from "sonner";
import Link from "next/link";
import { TaskTypeBadge } from "../task-type-badge";
import { CopyButton } from "@/components/ui/copy-button";
@@ -468,15 +466,10 @@ export function TaskHeader({ task, onAction }: TaskHeaderProps) {
return (
<div className="border-b pb-4">
<div className="flex items-start justify-between gap-4">
{/* Left: back arrow + title + metadata. This column SHRINKS and the
title truncates, so a long title never pushes the controls or the
{/* Left: title + metadata. This column SHRINKS and the title
truncates, so a long title never pushes the controls or the
Actions menu out of place. */}
<div className="flex items-start gap-3 min-w-0 flex-1">
<Link href="/tasks" prefetch={false}>
<Button variant="ghost" size="icon" className="shrink-0">
<ArrowLeft className="h-5 w-5" />
</Button>
</Link>
<div className="min-w-0 flex-1">
{/* Row 1: title only — editable, no UUID. Truncates on overflow. */}
{editingTitle ? (
@@ -0,0 +1,133 @@
"use client";
import { useEffect } from "react";
import { Task } from "@/types";
import { useScrollRestorationStore } from "@/lib/stores";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ChevronLeft, ChevronRight } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
interface TaskListNavProps {
task: Task;
}
const NO_CONTEXT_TOOLTIP =
"Open this task from the Tasks list to enable prev/next navigation within that list's filter/sort order.";
// Don't hijack Alt+Arrow while the user is typing (input/textarea/contenteditable).
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
);
}
// Moves to the adjacent task within the Tasks list order the user last
// visited (filters + sort applied), captured in useScrollRestorationStore by
// the Tasks list page. Documented fallback: when no list context exists for
// this session (task opened via a direct link, search result, notification,
// etc.) or the current task isn't part of the captured order (it was
// navigated to some other way), both buttons render disabled with a tooltip
// explaining why — there is no list order to fall back to, so we don't guess.
export function TaskListNav({ task }: TaskListNavProps) {
const router = useRouter();
const context = useScrollRestorationStore((state) => state.taskListNav);
const items = context?.items ?? [];
const index = items.findIndex((item) => item.id === task.id);
const hasContext = index !== -1;
const prevItem = hasContext && index > 0 ? items[index - 1] : null;
const nextItem =
hasContext && index < items.length - 1 ? items[index + 1] : null;
const query = context?.queryString ? `?${context.queryString}` : "";
// Alt+ArrowLeft/Right mirrors the prev/next buttons above.
useEffect(() => {
const handleKeydown = (e: KeyboardEvent) => {
if (!e.altKey || isEditableTarget(e.target)) return;
if (e.key === "ArrowLeft" && prevItem) {
e.preventDefault();
router.push(`/tasks/${prevItem.id}${query}`);
} else if (e.key === "ArrowRight" && nextItem) {
e.preventDefault();
router.push(`/tasks/${nextItem.id}${query}`);
}
};
window.addEventListener("keydown", handleKeydown);
return () => window.removeEventListener("keydown", handleKeydown);
}, [prevItem, nextItem, query, router]);
return (
<TooltipProvider>
<div className="flex items-center gap-1 shrink-0">
<NavButton
direction="prev"
item={prevItem}
query={query}
disabledReason={!hasContext ? NO_CONTEXT_TOOLTIP : undefined}
/>
<NavButton
direction="next"
item={nextItem}
query={query}
disabledReason={!hasContext ? NO_CONTEXT_TOOLTIP : undefined}
/>
</div>
</TooltipProvider>
);
}
function NavButton({
direction,
item,
query,
disabledReason,
}: {
direction: "prev" | "next";
item: { id: string; title: string } | null;
query: string;
disabledReason?: string;
}) {
const Icon = direction === "prev" ? ChevronLeft : ChevronRight;
const label = direction === "prev" ? "Previous task" : "Next task";
const disabled = item === null;
const button = (
<Button
variant="outline"
size="icon"
disabled={disabled}
aria-label={label}
asChild={!disabled}
>
{disabled ? (
<Icon className="h-4 w-4" />
) : (
<Link href={`/tasks/${item.id}${query}`} prefetch={false}>
<Icon className="h-4 w-4" />
</Link>
)}
</Button>
);
const tooltipText = disabledReason ?? item?.title ?? label;
return (
<Tooltip>
<TooltipTrigger asChild>
<span>{button}</span>
</TooltipTrigger>
<TooltipContent>{tooltipText}</TooltipContent>
</Tooltip>
);
}
+17 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect } from "react";
import { Task } from "@/types";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@@ -95,6 +95,10 @@ interface TaskTableProps {
// Controlled expanded state
expandedIds?: Set<string>;
onExpandedChange?: (ids: Set<string>) => void;
// Reports the currently visible, filtered + sorted task order (id + title
// pairs) — consumed by the Tasks list page to power task-detail prev/next
// navigation. Fires whenever the computed order changes.
onVisibleOrderChange?: (items: { id: string; title: string }[]) => void;
}
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
@@ -268,6 +272,7 @@ export function TaskTable({
onPageSizeChange,
expandedIds: controlledExpandedIds,
onExpandedChange,
onVisibleOrderChange,
}: TaskTableProps) {
// Internal state (used when not controlled)
const [internalSortConfig, setInternalSortConfig] =
@@ -384,6 +389,17 @@ export function TaskTable({
return flattenTree(sortedRoots, expandedIds);
}, [sortedRoots, expandedIds]);
// Report the visible order to the parent so task-detail can compute
// prev/next within this exact filter/sort context.
useEffect(() => {
onVisibleOrderChange?.(
flattenedTasks.map((node) => ({
id: node.task.id,
title: node.task.title,
})),
);
}, [flattenedTasks, onVisibleOrderChange]);
// Pagination on flattened list
const totalItems = flattenedTasks.length;
const totalPages = Math.ceil(totalItems / pageSize);
@@ -0,0 +1,32 @@
import { describe, it, expect } from "vitest";
import {
exceedsReadabilityThreshold,
READABILITY_LINE_THRESHOLD,
READABILITY_CHAR_THRESHOLD,
} from "@/lib/content-readability";
describe("exceedsReadabilityThreshold", () => {
it("is false for empty or short content", () => {
expect(exceedsReadabilityThreshold("")).toBe(false);
expect(exceedsReadabilityThreshold("A single short line.")).toBe(false);
});
it("is true once the line count exceeds the threshold", () => {
const lines = Array(READABILITY_LINE_THRESHOLD + 1)
.fill("x")
.join("\n");
expect(exceedsReadabilityThreshold(lines)).toBe(true);
});
it("is true once the character count exceeds the threshold", () => {
const long = "a".repeat(READABILITY_CHAR_THRESHOLD + 1);
expect(exceedsReadabilityThreshold(long)).toBe(true);
});
it("is false right at the thresholds", () => {
const atLineLimit = Array(READABILITY_LINE_THRESHOLD).fill("x").join("\n");
expect(exceedsReadabilityThreshold(atLineLimit)).toBe(false);
const atCharLimit = "a".repeat(READABILITY_CHAR_THRESHOLD);
expect(exceedsReadabilityThreshold(atCharLimit)).toBe(false);
});
});
+36
View File
@@ -0,0 +1,36 @@
/**
* Shared thresholds for auto-collapsing long content in the task detail
* view (CollapsibleSection, progress updates, checkpoints, acceptance
* criteria) so a task with a long history doesn't force continuous
* scrolling through fully-expanded sections.
*
* Usage:
* - CollapsibleSection passes `content` prop; component auto-collapses if content exceeds thresholds
* - TabProgress (ProgressUpdatesSection, CheckpointsSection) calls exceedsReadabilityThreshold() directly
* to decide if an entry defaults open (only 2 most recent open by default; others checked against thresholds)
* - AcceptanceCriteria passes criteria text to CollapsibleSection
*
* Examples:
* - "Step 1: Do X\nStep 2: Do Y" (2 lines, 26 chars) starts expanded
* - "Detailed description of implementation...\n...[11+ lines or >640 chars]" starts collapsed
*/
export const READABILITY_LINE_THRESHOLD = 10;
export const READABILITY_CHAR_THRESHOLD = 640;
/**
* True when content exceeds readability thresholds and should default to
* collapsed to keep the page scrollable on long tasks.
*
* A task with 30+ progress entries or 20+ acceptance criteria can fill the
* entire viewport when all sections are expanded. This function prevents
* that by collapsing sections with long individual entries, keeping the
* most-recent few visible while older/verbose entries require a click to expand.
*/
export function exceedsReadabilityThreshold(content: string): boolean {
if (!content) return false;
const lineCount = content.split("\n").length;
return (
lineCount > READABILITY_LINE_THRESHOLD ||
content.length > READABILITY_CHAR_THRESHOLD
);
}
+4
View File
@@ -1 +1,5 @@
export { useScrollRestorationStore } from "./scroll-restoration-store";
export type {
TaskListNavContext,
TaskListNavItem,
} from "./scroll-restoration-store";
@@ -15,6 +15,21 @@ interface ScrollPosition {
y: number;
}
// A single entry in the current Tasks list order (filter + sort applied),
// captured by the Tasks list page so the task-detail page can compute
// prev/next without re-implementing the list's filter/sort logic.
export interface TaskListNavItem {
id: string;
title: string;
}
export interface TaskListNavContext {
items: TaskListNavItem[];
// The Tasks list's current query string (filters/sort/search), so a "Back
// to list" link can restore the exact context the user navigated from.
queryString: string;
}
interface ScrollRestorationState {
// Scroll positions per route
scrollPositions: Record<string, ScrollPosition>;
@@ -28,6 +43,11 @@ interface ScrollRestorationState {
// Last visited routes per section (for "back" behavior)
lastVisited: Record<string, string>;
// Current Tasks list order (filter/sort context) for task-detail prev/next
// navigation. Null when no list has been visited this session — the
// documented fallback for task-detail is to disable prev/next then.
taskListNav: TaskListNavContext | null;
// Actions
setScrollPosition: (route: string, position: ScrollPosition) => void;
getScrollPosition: (route: string) => ScrollPosition | undefined;
@@ -42,6 +62,8 @@ interface ScrollRestorationState {
setLastVisited: (section: string, route: string) => void;
getLastVisited: (section: string) => string | undefined;
setTaskListNav: (context: TaskListNavContext) => void;
}
export const useScrollRestorationStore = create<ScrollRestorationState>()(
@@ -51,6 +73,7 @@ export const useScrollRestorationStore = create<ScrollRestorationState>()(
expandedSections: {},
selectedItems: {},
lastVisited: {},
taskListNav: null,
// Scroll position management
setScrollPosition: (route, position) =>
@@ -102,6 +125,8 @@ export const useScrollRestorationStore = create<ScrollRestorationState>()(
})),
getLastVisited: (section) => get().lastVisited[section],
setTaskListNav: (context) => set({ taskListNav: context }),
}),
{
name: "roboco-ui-state",
@@ -111,6 +136,7 @@ export const useScrollRestorationStore = create<ScrollRestorationState>()(
scrollPositions: state.scrollPositions,
expandedSections: state.expandedSections,
lastVisited: state.lastVisited,
taskListNav: state.taskListNav,
// Don't persist selectedItems - they're temporary
}),
},
+13
View File
@@ -4,3 +4,16 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Absolute inline timestamp shown alongside relative "Xm ago" text — e.g.
// "Jul 10, 2026, 3:45 PM". Shared so progress updates and checkpoints render
// the same format instead of each screen inventing its own.
export function formatAbsoluteTimestamp(timestamp: string): string {
return new Date(timestamp).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}