mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - People open tasks from their personal workspace and send replies to agents. > - Cold task opens should load recent conversation before older history. > - Classic settings could expand history before the Coordinator preference arrived. > - Coordinator lists also read full tasks, and response startup used the previous assignee. > - This pull request restores compact reads and keeps the selected responder visible through runtime discovery. > - People can read and reply without waiting for unrelated history or losing response feedback. ## Linked Issues or Issue Description Related: #17593, #17751, #17756, #17885, #17983, and #18092. **What happened?** A cold task could select Classic full history while its personal workspace preference was pending. The full-history choice stayed cached after Coordinator became active. Coordinator Tasks used full task reads. Needs attention fetched one enriched task per pending task. Sending to a newly selected agent could show no responder, show the previous assignee, or clear feedback when assignment changed. Legacy runtime discovery then replaced the startup identity with a generic activity line. **Expected behavior** Wait for the workspace preference before selecting Classic history. Show the task instruction and recent conversation with **More messages** for older content. Read collection indexes and fetch diagnostics for visible rows. Keep the selected responder mounted from Send through saved input and runtime startup. **Steps to reproduce** 1. Enable Classic task UI and Coordinator. Delay the personal workspace preference on a cold task open. 2. Open Coordinator Tasks or its Needs attention view with many pending tasks. 3. Open a task assigned to a person. Select another agent in the composer and send a message. 4. Let the message save before runtime reads return. Then allow a legacy run to appear. ## What Changed - Wait for task interface settings before persisting Classic history expansion. - Use lightweight indexes for Coordinator Tasks. Resolve pending task IDs in batches of at most 100. Keep search and participant filters on the server. Enrich at most 50 visible rows through the existing hook without replacing list membership or order. - Preserve an error when a pending task cannot load, instead of showing a false all-clear. - Register board cards for row enrichment. Observe newly mounted cards after column expansion or pagination, and track visibility by element so hidden list rows cannot clear visible board cards with the same task ID. - Track the recipient of each submitted message through reassignment. A later draft's recipient does not change the response already starting. - Share a stable response header across startup, native runtime, and legacy runtime rendering. Keep the existing transcript renderers. - Add regression tests, production-browser checks, design guidance, and feature-map notes. ## Verification - Red/green: delayed Coordinator preference started a full comments read before the fix. It now retains compact history and still expands on request. - Red/green: sends from human or other-agent assignment showed no identity or the wrong identity. Both now retain the selected responder through assignment, acknowledgement, later draft selection, and native/legacy startup. - In a 105-task Needs attention fixture, the previous code made 105 enriched detail requests. The fixed code makes two bounded index requests. - All 659 final affected tests pass across task detail, thread, runner turn, history window, Inbox, board, row enrichment, and Coordinator collection suites. - Review follow-up: the production board lost its next-step badge before card registration. The regression now passes, along with column pagination. The focused 82-test enrichment run and 371-test header/thread run also pass. - All 12 compact-startup browser checks pass against the production UI and an isolated PostgreSQL container. They cover delayed preferences, list navigation, saved-card geometry, document/comment anchors, history expansion, draft retention, retry, and actual legacy process startup. The final deterministic process case recorded 118 frames with zero missing or replaced responder identities. It does not call a model provider. - Full workspace build and typecheck pass. Final UI build and typecheck pass after the shared-header change. Token gates, map checks, module boundaries, and diff checks pass. - The full UI rerun before the board review follow-up passed all 10,034 tests across 844 files. The 659 affected tests pass after that follow-up. The first full UI run passed 10,030 tests and timed out in one unchanged IssuesList large-tree test. Its two variants pass in an isolated rerun and in the full rerun. Those later passes establish recovery; the earlier timeout's cause remains unproven. - The full `pnpm test:run` attempt was stopped after repeated embedded PostgreSQL initialization failures in server fixtures. No complete local server-suite pass is claimed. Browser verification recovered with a disposable container database. - A later avatar browser rerun reused the previous same-title task and its cancelled run through the existing 48-hour title deduplication. That fixture never reached fresh runtime startup. The test now uses a unique title and checks its initial assignment/status; a full 12-case rerun and a second avatar run on the same instance pass. The failed trace is retained locally. - Final commit `7a18372da3` has 55 successful checks, two intentionally skipped Storybook checks, and one neutral Paperclip Review. All required verification gates are green. Greptile is 5/5 with no actionable findings or open review threads. The advisory Paperclip review paused without a complete assessment; no passing Paperclip assessment is claimed. - These checks establish bounded reads and stable rendering. No reliable staging latency benchmark or staging deployment is claimed. ## Risks - Collection projections omit task bodies. Detail navigation must retain its normal core/full task reads. Search remains server-side. - Startup feedback must end on rejection, unavailable agents, terminal outcomes, or a visible run. Existing cases cover those transitions. - The shared header changes the legacy live-turn layout to retain identity beside its existing status line. - No migration, mutation permission, provider protocol, or execution authority change. ## Model Used OpenAI Codex, GPT-6 family. The exact serving deployment ID and context-window size are not exposed in this session. The model used reasoning, code editing, shell tools, automated Chromium checks, and manual browser inspection. ## 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 (focused suites and browser checks; full-suite limitations are recorded above) - [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 - [x] All Paperclip CI gates are green - [x] 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: Paperclip <noreply@paperclip.ing>
113 lines
4.7 KiB
TypeScript
113 lines
4.7 KiB
TypeScript
import { useEffect, useMemo, useState, type RefObject } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import type { Issue } from "@paperclipai/shared";
|
|
import { issuesApi } from "../api/issues";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
|
|
/** Enrich visible rows without replacing the index that owns membership and order. */
|
|
export function mergeIssueRowDetails(issue: Issue, details: Issue | undefined): Issue {
|
|
if (!details || details.id !== issue.id || details.companyId !== issue.companyId
|
|
|| new Date(details.updatedAt).getTime() < new Date(issue.updatedAt).getTime()) return issue;
|
|
return {
|
|
...issue,
|
|
blockerAttention: details.blockerAttention,
|
|
reviewAttention: details.reviewAttention,
|
|
activeRecoveryAction: details.activeRecoveryAction,
|
|
interruption: details.interruption,
|
|
successfulRunHandoff: details.successfulRunHandoff,
|
|
scheduledRetry: details.scheduledRetry,
|
|
};
|
|
}
|
|
|
|
export function useIssueRowDetails(
|
|
companyId: string | null | undefined,
|
|
root: RefObject<HTMLElement | null>,
|
|
rowIdentity: string,
|
|
enabled: boolean,
|
|
issues: readonly Issue[],
|
|
) {
|
|
const [visibleIds, setVisibleIds] = useState<string[]>([]);
|
|
const [saved, setSaved] = useState<{ companyId: string; rows: Map<string, Issue> } | null>(null);
|
|
useEffect(() => {
|
|
const element = root.current;
|
|
if (!enabled || !element) {
|
|
setVisibleIds([]);
|
|
return;
|
|
}
|
|
const selector = "[data-issue-row-details]";
|
|
const observed = new Set<HTMLElement>();
|
|
const visible = new Set<HTMLElement>();
|
|
const publish = () => {
|
|
const next = [...new Set([...visible].map(node => node.dataset.issueRowDetails!))].sort().slice(0, 50);
|
|
setVisibleIds((previous) => previous.join(",") === next.join(",") ? previous : next);
|
|
};
|
|
const observer = typeof IntersectionObserver === "undefined" ? null : new IntersectionObserver((entries) => {
|
|
for (const entry of entries) {
|
|
const node = entry.target as HTMLElement;
|
|
if (!observed.has(node)) continue;
|
|
if (entry.isIntersecting) visible.add(node);
|
|
else visible.delete(node);
|
|
}
|
|
publish();
|
|
});
|
|
const syncRows = () => {
|
|
const nodes = new Set(element.querySelectorAll<HTMLElement>(selector));
|
|
for (const node of observed) if (!nodes.has(node)) {
|
|
observer?.unobserve(node);
|
|
observed.delete(node);
|
|
visible.delete(node);
|
|
}
|
|
for (const node of nodes) if (!observed.has(node)) {
|
|
observed.add(node);
|
|
observer?.observe(node);
|
|
}
|
|
if (!observer) {
|
|
visible.clear();
|
|
for (const node of [...nodes].filter(node => !node.closest("[hidden]")).slice(0, 50)) visible.add(node);
|
|
}
|
|
publish();
|
|
};
|
|
syncRows();
|
|
// Board pagination and collapsed lanes can mount rows without changing the
|
|
// collection. Observe only added/removed row markers, not badge/text updates.
|
|
const mutations = new MutationObserver(records => {
|
|
if (records.some(record => [...record.addedNodes, ...record.removedNodes].some(node =>
|
|
node instanceof Element && (node.matches(selector) || node.querySelector(selector)),
|
|
))) syncRows();
|
|
});
|
|
mutations.observe(element, { childList: true, subtree: true });
|
|
return () => { observer?.disconnect(); mutations.disconnect(); };
|
|
}, [companyId, root, rowIdentity, enabled]);
|
|
|
|
const versions = new Map<string, number>();
|
|
for (const issue of issues) {
|
|
versions.set(issue.id, Math.max(versions.get(issue.id) ?? 0, new Date(issue.updatedAt).getTime()));
|
|
}
|
|
const visibleVersions = visibleIds.map((id) => [id, versions.get(id)]);
|
|
const { data, error, refetch } = useQuery({
|
|
queryKey: [...queryKeys.issues.list(companyId ?? "__disabled__"), "visible-row-details", visibleVersions],
|
|
queryFn: ({ signal }) => issuesApi.listCompact(companyId!, {
|
|
ids: visibleIds, projection: "rows", includeRoutineExecutions: true, limit: 50,
|
|
}, { signal }),
|
|
enabled: enabled && !!companyId && visibleIds.length > 0,
|
|
staleTime: 30_000,
|
|
});
|
|
useEffect(() => {
|
|
if (!companyId || !data) return;
|
|
setSaved((previous) => {
|
|
const rows = new Map(previous?.companyId === companyId ? previous.rows : []);
|
|
for (const issue of data) {
|
|
const previousRow = rows.get(issue.id);
|
|
if (!previousRow || new Date(issue.updatedAt).getTime() >= new Date(previousRow.updatedAt).getTime()) {
|
|
rows.set(issue.id, issue as Issue);
|
|
}
|
|
}
|
|
return { companyId, rows };
|
|
});
|
|
}, [companyId, data]);
|
|
const enrichIssueRow = useMemo(() => (issue: Issue) => mergeIssueRowDetails(
|
|
issue, saved && saved.companyId === companyId ? saved.rows.get(issue.id) : undefined,
|
|
), [companyId, saved]);
|
|
return { enrichIssueRow, error, retry: refetch };
|
|
}
|