feat(ui): refine the chat-style task workflow (#11263)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The issue detail view is the main place where people read work and
guide agents.
> - The chat-style task view needs clear messages, controls, properties,
and document feedback.
> - Dense metadata and disconnected controls make active work harder to
scan.
> - This pull request refines the existing chat-style task workflow
across desktop and mobile layouts.
> - The benefit is a clearer issue thread with faster access to the
controls that guide work.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The change improves the chat-style issue detail view that was introduced
in [#10606](https://github.com/paperclipai/paperclip/pull/10606) and
expanded in
[#10707](https://github.com/paperclipai/paperclip/pull/10707).

**Current behavior**

The issue thread spreads task controls across the page. Agent-turn
metadata competes with the message content. Document annotation comments
use inline placement that limits the document reading area. The mobile
composer can overlap the bottom navigation.

**Proposed behavior**

The issue view keeps the thread focused on message content. It moves
supporting controls into the properties area, adds searchable
assignment, restores the sub-task tree, docks document comments in a
side gutter, and keeps the mobile composer clear of navigation.

**Reason and benefit**

People can scan active work faster and find task controls without
leaving the issue. The layout also gives documents and mobile
conversations more usable space.

**Breaking changes**

None. The change updates presentation and interaction behavior in the
existing issue UI.

## What Changed

- Refined task-chat message spacing, metadata, agent bubbles, and
composer alignment.
- Added searchable assignment and restored sub-task navigation in the
properties pane.
- Moved document annotation comments into a right-side gutter.
- Kept the mobile composer above the auto-hiding bottom navigation.
- Added and updated focused component tests for the changed
interactions.

## Verification

- `pnpm check:token-gates`
- `TZ=UTC pnpm --filter @paperclipai/ui exec vitest run
src/components/InlineEntitySelector.test.tsx
src/components/IssueDocumentAnnotations.test.tsx
src/components/IssueProperties.test.tsx
src/components/TaskChatThread.test.tsx src/pages/IssueDetail.test.tsx`
- The token gates report 3/3 clean.
- The focused test run passes 124 tests in 5 files.
- Visual snapshot baselines were not updated. This follows the
`doc/design/DECISION-SHEET.md` entry "Per-change snapshot verification
demoted to dormant (Jul 13 2026)."

## Risks

- The changes affect several related issue-detail layouts. A browser
review should cover desktop and mobile widths before merge.
- The monitor-row test formats time in the host timezone. The
verification command sets `TZ=UTC` to match CI.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex with GPT-5. The context-window size is not exposed in
this environment. The model used reasoning, repository tools, code
execution, and GitHub tools.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
authored and GitHub committed 2026-08-12 11:41:40 -07:00
1 parent ff5fd62d07
commit 0ee0543e5b
36 files changed
+1603 -664

No files matched your search

@@ -8,6 +8,7 @@
import type { TranscriptEntry } from "@/adapters";
import type {
TaskChatDiff,
TaskChatActivityPhaseItem,
TaskChatItem,
TaskChatToolItem,
TaskChatTurnChildItem,
@@ -38,7 +39,7 @@ export function isTerminalRunStatus(status: string | undefined | null): boolean
* in the thread outside.
*/
export function isNestableLiveChild(item: TaskChatItem): item is TaskChatTurnChildItem {
return item.kind === "tool" || item.kind === "usage";
return item.kind === "tool" || item.kind === "usage" || item.kind === "activity_phase";
}
/**
@@ -328,18 +329,80 @@ export function transcriptToTaskChatItems(
}
/**
* A settled run's nested children (PAP-361): exactly the tool rows (plus usage
* readouts) in transcript order, under the "✓ Worked ·" summary — parity with
* the summary's tool count. Messages are excluded: the final reply already
* landed as the run's posted comment bubble, and interstitial updates are
* ephemeral (they take the live line while streaming, then vanish). Thinking
* is excluded too — the run log / classic transcript remain its archive.
* A settled run's nested children: activity phases containing chronological
* tool rows and their historical interstitial boundary. The final reply is
* excluded because its posted comment is canonical. Thinking stays in the
* run log / classic transcript.
*/
export function settledRunChildren(parsed: readonly TaskChatItem[]): TaskChatTurnChildItem[] {
return parsed.filter(
(it): it is TaskChatTurnChildItem =>
it.kind !== "turn" && it.kind !== "message" && it.kind !== "thinking",
);
return buildActivityPhases(parsed, false);
}
function phaseSummary(items: readonly (TaskChatToolItem | { kind: "usage" })[]): string {
const counts = new Map<string, number>();
let generic = 0;
for (const item of items) {
if (item.kind !== "tool") continue;
if (isGenericToolName(item.rawName ?? item.name)) {
generic += 1;
continue;
}
const family = toolTaxonomy(item.rawName ?? item.name).family;
counts.set(family, (counts.get(family) ?? 0) + 1);
}
const phrases: string[] = [];
const add = (family: string, verb: string, singular: string, plural: string) => {
const count = counts.get(family) ?? 0;
if (count) phrases.push(`${verb} ${count} ${count === 1 ? singular : plural}`);
};
add("read", "Read", "file", "files");
add("edit", "Edited", "file", "files");
add("terminal", "Ran", "command", "commands");
const searched = (counts.get("grep") ?? 0) + (counts.get("search") ?? 0);
if (searched) phrases.push(`Searched ${searched} ${searched === 1 ? "time" : "times"}`);
const known = new Set(["read", "edit", "terminal", "grep", "search"]);
const other = [...counts].reduce((n, [family, count]) => n + (known.has(family) ? 0 : count), 0) + generic;
if (other) phrases.push(`Called ${other} ${other === 1 ? "tool" : "tools"}`);
return phrases.join(", ") || "No tool activity";
}
/** Segment parsed transcript rows at assistant boundaries with stable run-derived ids. */
export function buildActivityPhases(
parsed: readonly TaskChatItem[],
running: boolean,
): TaskChatActivityPhaseItem[] {
const phases: TaskChatActivityPhaseItem[] = [];
let current: TaskChatActivityPhaseItem | null = null;
const ensureOpening = (seed: string) => {
if (!current) {
current = { id: `${seed}:phase:opening`, kind: "activity_phase", items: [], summary: "", active: false };
phases.push(current);
}
return current;
};
const lastVisible = [...parsed].reverse().find((item) => item.kind !== "thinking");
for (const item of parsed) {
if (item.kind === "message") {
// A settled transcript's trailing assistant text is the posted reply.
// Live/settle-gap tails keep it visible until that canonical reply lands.
if (!running && item === lastVisible) continue;
current = {
id: `${item.id}:phase`,
kind: "activity_phase",
interstitial: item,
items: [],
summary: "",
active: false,
};
phases.push(current);
} else if (item.kind === "tool" || item.kind === "usage") {
ensureOpening(item.id).items.push(item);
}
}
for (const phase of phases) phase.summary = phaseSummary(phase.items);
const meaningful = phases.filter((phase) => phase.interstitial || phase.items.length > 0);
if (running && meaningful.length) meaningful[meaningful.length - 1].active = true;
return meaningful;
}
function formatDurationLabel(ms: number): string | undefined {