forked from farhoodlabs/paperclip
df425fde96
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - Operators use issue detail pages and child issue lists to understand multi-step execution plans. > - Ordered sub-issues currently read like a flat table, so dependency chains and current next steps are harder to scan. > - The branch work adds a workflow-oriented presentation for child issues without changing the single-assignee task model. > - This pull request makes ordered sub-issues read more like a progress checklist while preserving normal issue list controls. > - The benefit is that operators can see completed steps, active work, blocked follow-ups, and dependency order at a glance. ## What Changed - Added workflow sorting utilities and tests for dependency-aware child issue ordering. - Added sub-issue progress summary, checklist numbering, current-step affordances, blocker context, and done-state de-emphasis in the issue list UI. - Wired issue detail sub-issue panels to use the workflow sort/progress checklist presentation. - Updated issue service behavior/tests for child issue ordering inputs used by the UI. - Added a Storybook visual review fixture and screenshot helper for the sub-issue workflow checklist surface. ## Verification - `pnpm run preflight:workspace-links && pnpm exec vitest run server/src/__tests__/issues-service.test.ts ui/src/components/IssueRow.test.tsx ui/src/components/IssuesList.test.tsx ui/src/pages/IssueDetail.test.tsx ui/src/lib/issue-detail-subissues.test.ts ui/src/lib/workflow-sort.test.ts` - Result: 6 test files passed, 55 tests passed, 34 embedded Postgres issue-service tests skipped because `@embedded-postgres/darwin-x64` is unavailable on this host. - Visual review: generated Storybook screenshots from the existing local Storybook server on port 6006 with `node scripts/screenshot-subissues.mjs /tmp/pap-2189-subissues-screens http://localhost:6006`. - Screenshot artifacts: - Desktop dark:  - Desktop light:  - Mobile dark:  - Mobile light:  - Local Storybook note: starting a second Storybook process selected port 6008 because 6006 was occupied, then Vite failed with an esbuild host/binary version mismatch (`0.25.12` host vs `0.27.3` binary). The already-running Storybook server on 6006 served the fixture successfully for screenshots. ## Risks - Medium UI risk: the issue list now has additional sub-issue-specific visual states, so dense lists should be checked for spacing and scanability. - Low ordering risk: workflow sorting is covered by focused unit tests, but unusual dependency topologies may still need reviewer attention. - No migration risk: this PR does not add database migrations or touch `pnpm-lock.yaml`. > 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, GPT-5 coding agent, tool-enabled shell/git/GitHub workflow. Context window is runtime-provided and not exposed in this environment. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
118 lines
3.7 KiB
TypeScript
118 lines
3.7 KiB
TypeScript
export type WorkflowSortBlocker = { id: string };
|
|
|
|
export type WorkflowSortIssue = {
|
|
id: string;
|
|
createdAt: Date | string;
|
|
blockedBy?: WorkflowSortBlocker[] | null;
|
|
};
|
|
|
|
// Orders siblings so that blocker chains stay contiguous (predecessor emitted
|
|
// immediately before its successor) when the graph is linear enough to allow
|
|
// it. Branches, merges, and cross-parent blockers stop the chain walk and send
|
|
// control back to the ready queue, where creation order (then id) breaks ties.
|
|
//
|
|
// Blockers whose id is absent from the input are treated as absent for
|
|
// ordering — the row chip can still surface them visually later.
|
|
//
|
|
// If the input contains a cycle (API rejects this, so it shouldn't happen in
|
|
// practice), the util degrades to a pure tie-break sort instead of hanging.
|
|
export function workflowSort<T extends WorkflowSortIssue>(issues: T[]): T[] {
|
|
if (issues.length <= 1) return [...issues];
|
|
|
|
const tieBreakAsc = (a: T, b: T): number => {
|
|
const ta = toTimestamp(a.createdAt);
|
|
const tb = toTimestamp(b.createdAt);
|
|
if (ta !== tb) return ta - tb;
|
|
if (a.id < b.id) return -1;
|
|
if (a.id > b.id) return 1;
|
|
return 0;
|
|
};
|
|
|
|
const byId = new Map<string, T>();
|
|
for (const issue of issues) byId.set(issue.id, issue);
|
|
|
|
const successors = new Map<string, string[]>();
|
|
const inDegree = new Map<string, number>();
|
|
for (const issue of issues) {
|
|
successors.set(issue.id, []);
|
|
inDegree.set(issue.id, 0);
|
|
}
|
|
for (const issue of issues) {
|
|
const seenBlockers = new Set<string>();
|
|
for (const blocker of issue.blockedBy ?? []) {
|
|
if (!blocker || !byId.has(blocker.id)) continue;
|
|
if (blocker.id === issue.id) continue;
|
|
if (seenBlockers.has(blocker.id)) continue;
|
|
seenBlockers.add(blocker.id);
|
|
successors.get(blocker.id)!.push(issue.id);
|
|
inDegree.set(issue.id, (inDegree.get(issue.id) ?? 0) + 1);
|
|
}
|
|
}
|
|
|
|
for (const ids of successors.values()) {
|
|
ids.sort((a, b) => tieBreakAsc(byId.get(a)!, byId.get(b)!));
|
|
}
|
|
|
|
const ready: T[] = [];
|
|
for (const issue of issues) {
|
|
if (inDegree.get(issue.id) === 0) ready.push(issue);
|
|
}
|
|
ready.sort(tieBreakAsc);
|
|
|
|
const emitted = new Set<string>();
|
|
const output: T[] = [];
|
|
|
|
const insertReady = (issue: T): void => {
|
|
let lo = 0;
|
|
let hi = ready.length;
|
|
while (lo < hi) {
|
|
const mid = (lo + hi) >>> 1;
|
|
if (tieBreakAsc(ready[mid], issue) <= 0) lo = mid + 1;
|
|
else hi = mid;
|
|
}
|
|
ready.splice(lo, 0, issue);
|
|
};
|
|
|
|
const releaseSuccessors = (id: string): void => {
|
|
for (const succId of successors.get(id) ?? []) {
|
|
if (emitted.has(succId)) continue;
|
|
const remaining = (inDegree.get(succId) ?? 0) - 1;
|
|
inDegree.set(succId, remaining);
|
|
if (remaining === 0) {
|
|
const succ = byId.get(succId);
|
|
if (succ) insertReady(succ);
|
|
}
|
|
}
|
|
};
|
|
|
|
while (ready.length > 0) {
|
|
let current = ready.shift()!;
|
|
while (current && !emitted.has(current.id)) {
|
|
output.push(current);
|
|
emitted.add(current.id);
|
|
releaseSuccessors(current.id);
|
|
|
|
const succIds = successors.get(current.id) ?? [];
|
|
if (succIds.length !== 1) break;
|
|
const nextId = succIds[0];
|
|
if (emitted.has(nextId)) break;
|
|
if ((inDegree.get(nextId) ?? 0) !== 0) break;
|
|
const nextIndex = ready.findIndex((issue) => issue.id === nextId);
|
|
if (nextIndex < 0) break;
|
|
[current] = ready.splice(nextIndex, 1);
|
|
}
|
|
}
|
|
|
|
if (emitted.size < issues.length) {
|
|
return [...issues].sort(tieBreakAsc);
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
function toTimestamp(value: Date | string | null | undefined): number {
|
|
if (!value) return 0;
|
|
const ts = value instanceof Date ? value.getTime() : new Date(value).getTime();
|
|
return Number.isFinite(ts) ? ts : 0;
|
|
}
|