mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 17:51:58 +01:00
fix(ui): reveal task conversations without loading layout shifts (#18545)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Tasks combine saved messages, documents, run output, monitors, and
decisions.
> - Compact history makes long tasks load quickly.
> - The loading view still showed the real task header before the thread
scrolled to recent messages.
> - Different loading frames also changed the viewport, composer bounds,
and header typography.
> - This pull request uses one loading frame and reveals the prepared
conversation once.
> - The benefit is a stable first view without a material loading-speed
penalty.
## Linked Issues or Issue Description
**What happened?**
A cold task open could show a generic skeleton, then the real title and
monitor above placeholder messages, then jump to recent content. The
loading view changed width on desktop and height on mobile. Its title
line height differed from the real title.
**Expected behavior**
Keep the loading viewport and composer in place. Prepare the initial
reading position out of view. Reveal the recent conversation once its
data, fonts, scroll position, and composer bounds settle. Cap the font
wait at three seconds if the font host does not answer.
**Steps to reproduce**
1. Open a task with a long title, long saved replies, document cards, a
monitor, and a pending decision.
2. Reload it with CPU and network throttling.
3. Watch the header appear above placeholders, then move offscreen when
the recent conversation appears.
**Paperclip version or commit**
e122478368. The synthetic baseline recorded 10 animation frames with a
real header visible before the conversation was ready.
**Deployment mode**
Built from source. Local production previews used the same disposable
PostgreSQL fixture and API.
Related work: #17593, #16878, #18092, and #18461 preserve compact
startup and saved-card behavior. Older open PR #14667 addressed
transcript reveal and reading anchors; this change targets the remaining
loading-frame transition on current master.
## What Changed
- Prepare task data while the personal layout preference loads. Keep the
temporary page hidden until the correct layout is known. This removes a
12 px mobile header change.
- Share one matching loading frame across task metadata and history
preparation. Use the real composer surface and dock geometry for its
placeholder.
- Keep the real title and monitor concealed until the viewport, scroll
position, composer, and fonts settle. Bound a stalled font request to
three seconds.
- Reserve the final chat viewport and breadcrumb identifier position.
Mount task properties after the personal preference resolves.
- Match the loading title typography to the selected task interface,
including Classic.
- Add normal-CI browser coverage for rich tasks, Classic startup and
typography, mobile layout selection, and stalled fonts. Preserve the
outage recovery assertions.
- Add Design Guide examples and an optional retained fixture with 200
long messages, documents, a PR card, a monitor, and a pending
confirmation. Disable both interval and on-demand agent wakes in this
fixture.
## Verification
- Final UI source: `8398af3493`. The later commits change tests or merge
master. The recorded production build is `99867e2dc3`; its UI source is
identical to the final PR head.
- All 10,263 UI tests pass: `pnpm --filter @paperclipai/ui exec vitest
run`. Workspace typecheck and the full production build pass. Token
gates, module boundaries, map validation, and diff checks pass.
- Task browser checks pass 40 cases. Shared chat passes 22 cases. The
normal-CI loading and outage recovery suite passes all 13 cases on the
final head.
- The eight rich loading cases cover direct and list entry, delayed
fonts, and delayed personal preferences on desktop and mobile. At 4× CPU
slowdown and 80 ms latency, each records zero real-header frames before
reveal. Loading-frame, composer, and initial reading-position movement
are 0 px. Title typography matches.
- The normal browser suite also verifies that unresolved mobile layouts
stay hidden, the selected mobile viewport stays fixed, and a seeded
Classic loading title matches the resolved title after an uncached
reload.
- Cold speed: five fresh contexts per variant and viewport, alternating
before/after order. Same local API and synthetic fixture; 4× CPU
slowdown, 80 ms latency, 1.18 MB/s download. No interception,
recordings, builds, or test suites during measurement. Desktop median:
3578 ms before, 3572 ms after. Mobile median: 3500 ms before, 3476 ms
after. Sample ranges overlap.
- The unmodified baseline records 10 real-header frames before
readiness. The final eight-case matrix records zero. Local recordings
and failed prototype evidence are retained outside the repository. No
private staging content is published in this PR.
- CI initially counted hidden zero-size elements as visible frames. Its
saved data confirms the `(0, 0, 0, 0)` entries. Both browser samplers
now ignore those entries and retain all visible geometry checks. The
corrected local suite passes.
- The default `pnpm test:run` command stopped in the unchanged server
chat integration shard with `No test found ... in lines ...` and 1817
skipped tests. This is not a local full-suite pass. One unchanged
AgentActionButtons test also failed during a concurrent run; all 37
cases pass in isolation, and the full 10,263-test UI rerun passes
without source changes to that component.
- Final-head [CI
passes](https://github.com/paperclipai/paperclip/actions/runs/41320240728)
at `d1ef4a955f`. No checks are failed or pending. Greptile and Ponytail
Paperclip Review both report 5/5 for that exact head. All review threads
are resolved.
- The fix is not deployed to staging.
## Risks
- First reveal waits for stable geometry and fonts, with a three-second
font cap. A font that arrives after this degraded-mode cap can still
change text metrics. The existing history deadline still exposes usable
content if another dependency stalls.
- The loading frame cannot predict arbitrary conversation content. The
real content remains concealed until its initial position is ready.
- The shared thread also serves agent chat. Its browser startup and
scroll journeys are included in final verification.
## Model Used
OpenAI GPT-6 (Codex), with reasoning, tool use, code execution, and
browser inspection. The host did not expose a more specific model ID or
the context-window size.
## 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
- [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>
This commit is contained in:
1 parent
8ea240218d
commit
f6fc336a35
22 files changed
+717
-129
No files matched your search
@@ -59,6 +59,19 @@ Filters → **Routine tasks** selects tasks created by routine runs. The
|
||||
|
||||
### `task-detail`
|
||||
|
||||
During a cold open, task data prepares behind the shell loading view until the
|
||||
personal presentation preference resolves. The temporary shell stays hidden.
|
||||
The matching chat loading frame then owns the same viewport and
|
||||
composer geometry as the resolved conversation. The real title and monitor
|
||||
banner stay concealed until the initial reading position is prepared. The
|
||||
[rich loading journey](../../tests/perf/issue-detail/loading-layout.spec.ts)
|
||||
checks direct and list-based entry, delayed fonts and preferences, and desktop/mobile geometry
|
||||
with a long saved thread, cards, a monitor, and a pending confirmation. This
|
||||
synthetic local coverage does not establish a deployed staging pass. The normal
|
||||
[browser CI journey](../../tests/e2e/task-loading-frame.spec.ts) also covers the
|
||||
loading contract, Classic preference loading and typography, stable mobile chrome,
|
||||
and a bounded wait for stalled fonts.
|
||||
|
||||
Open `/issues/:issueId` from a row, mention, or direct link. Cold opens show the instruction, recent exchange and saved cards together. Completed runs with saved replies defer their transcripts until the reader opens history. Live runs, failures, yielded runs and runs without a saved reply still hydrate; an already shown transcript stays available after completion. No URL fragment expands history; older-comment anchors fetch only their target, and document anchors open the pane. **More messages** appears only when earlier comments or runs are omitted and loads that history. Fresh tasks with no omitted history have no disclosure. A single run with deferred output or a truncated legacy log offers **More run output** instead. **More task actions → Show task activity** loads full activity even for a single native run; incoming messages remain visible while the task stays open.
|
||||
|
||||
### `task-sharing`
|
||||
|
||||
@@ -95,7 +95,7 @@ test("two cached tasks keep separate title saves while the browser is offline",
|
||||
}
|
||||
});
|
||||
|
||||
test("a queued startup save survives the first session lookup during an API outage", async ({ page, request }) => {
|
||||
test("a cold task waits for the first session and retains a title saved during an API outage", async ({ page, request }) => {
|
||||
const company = await json(await request.post("/api/companies", { data: { name: `Startup recovery ${Date.now()}` } }));
|
||||
const session = await json(await request.get("/api/auth/get-session"));
|
||||
let releaseSession!: () => void;
|
||||
@@ -117,8 +117,17 @@ test("a queued startup save survives the first session lookup during an API outa
|
||||
await sessionReady;
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(session) });
|
||||
});
|
||||
const coreReady = page.waitForResponse(response => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname === `/api/issues/${issue.identifier}` && url.searchParams.get("view") === "core" && response.ok();
|
||||
});
|
||||
await page.goto(`/${company.issuePrefix}/issues/${issue.identifier}`);
|
||||
await coreReady;
|
||||
const header = page.getByTestId("issue-detail-header");
|
||||
await expect(page.getByTestId("shell-configuration-loading")).toBeVisible();
|
||||
await expect(header).toHaveCount(0);
|
||||
releaseSession();
|
||||
await expect(page.getByTestId("task-chat-history-content")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(header.getByRole("heading", { name: issue.title, exact: true })).toBeVisible();
|
||||
apiUnavailable = true;
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("offline")));
|
||||
@@ -127,7 +136,6 @@ test("a queued startup save survives the first session lookup during an API outa
|
||||
await header.locator("textarea").fill(savedTitle);
|
||||
await header.locator("textarea").press("Enter");
|
||||
expect((await json(await request.get(`/api/issues/${issue.id}`))).title).toBe(issue.title);
|
||||
releaseSession();
|
||||
await expect.poll(() => page.evaluate(({ owner, title }) => Object.keys(localStorage).some(key =>
|
||||
key.startsWith(`paperclip:inline-draft:v2:${owner}:`) && JSON.parse(localStorage.getItem(key) ?? "null")?.draft === title),
|
||||
{ owner: session.user.id, title: savedTitle })).toBe(true);
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { expect, test, type APIRequestContext, type APIResponse } from "@playwright/test";
|
||||
|
||||
async function json(response: APIResponse) {
|
||||
expect(response.ok(), `${response.status()} ${await response.text()}`).toBe(true);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function createTask(request: APIRequestContext) {
|
||||
const company = await json(await request.post("/api/companies", { data: { name: "Loading frame verification" } }));
|
||||
const title = "Investigate a long-running workspace and preserve its saved findings, document cards, and pending review without moving the initial reading position";
|
||||
const issue = await json(await request.post(`/api/companies/${company.id}/issues`, { data: {
|
||||
title, status: "backlog", description: "## Saved investigation\n\n" + "Preserve the original report and all saved evidence. ".repeat(20),
|
||||
} }));
|
||||
return { company, issue, title, route: `/${company.issuePrefix}/issues/${issue.identifier}` };
|
||||
}
|
||||
|
||||
for (const mobile of [false, true]) test(`a rich task uses one stable loading frame (${mobile ? "mobile" : "desktop"})`, async ({ page, request }, info) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1280, height: 720 });
|
||||
const fixture = await createTask(request);
|
||||
try {
|
||||
for (let index = 0; index < 20; index++) await json(await request.post(`/api/issues/${fixture.issue.id}/comments`, { data: {
|
||||
body: `## Saved findings ${index}\n\n` + "Checked the recorded evidence and retained the result for review. ".repeat(12),
|
||||
} }));
|
||||
await json(await request.put(`/api/issues/${fixture.issue.id}/documents/plan`, { data: {
|
||||
title: "Investigation plan", format: "markdown", body: "# Plan\n\nKeep the findings with the original task.",
|
||||
} }));
|
||||
await json(await request.post(`/api/issues/${fixture.issue.id}/work-products`, { data: {
|
||||
type: "pull_request", provider: "github", title: "Saved investigation patch", status: "active",
|
||||
url: "https://github.com/example/layout-fixture/pull/42", metadata: { repository: "example/layout-fixture", number: 42 },
|
||||
} }));
|
||||
await json(await request.post(`/api/issues/${fixture.issue.id}/interactions`, { data: {
|
||||
kind: "request_confirmation", title: "Review the saved patch", continuationPolicy: "none",
|
||||
payload: { version: 1, prompt: "Approve the saved findings?", acceptLabel: "Approve patch", rejectLabel: "Request changes" },
|
||||
} }));
|
||||
await page.route("**/api/workspace-preferences/me", async route => {
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ json: { ...await response.json(), coordinatorWorkspaceEnabled: true } });
|
||||
});
|
||||
await page.addInitScript(() => {
|
||||
const frames: Array<{ ready: boolean; headerVisible: boolean; loading: number[] | null; loadingComposer: number[] | null; composer: number[] | null; latestY?: number; scrollHeight?: number; loadingFont?: string; titleFont?: string }> = [];
|
||||
Object.assign(window, { taskLoadingFrames: frames });
|
||||
const sample = () => {
|
||||
const box = (selector: string) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element || getComputedStyle(element).visibility !== "visible") return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return null;
|
||||
return [rect.x, rect.y, rect.width, rect.height];
|
||||
};
|
||||
const font = (selector: string) => {
|
||||
const element = document.querySelector(selector);
|
||||
return element ? getComputedStyle(element).font : undefined;
|
||||
};
|
||||
const header = document.querySelector('[data-testid="issue-detail-header"]');
|
||||
const bounds = header?.getBoundingClientRect();
|
||||
frames.push({
|
||||
ready: !!document.querySelector('[data-testid="task-chat-thread"] > [aria-busy="false"]'),
|
||||
headerVisible: !!header && getComputedStyle(header).visibility === "visible" && bounds!.bottom > 60 && bounds!.top < innerHeight,
|
||||
loading: box('[data-testid="task-chat-loading-frame"]'),
|
||||
loadingComposer: box('[data-testid="task-chat-loading-composer"] .paperclip-task-chat-composer'),
|
||||
composer: box('[data-testid="task-chat-composer-stack"] .paperclip-task-chat-composer'),
|
||||
latestY: [...document.querySelectorAll('[id^="comment-"]')].at(-1)?.getBoundingClientRect().y,
|
||||
scrollHeight: document.querySelector('[data-testid="task-chat-scroller"]')?.scrollHeight,
|
||||
loadingFont: font('[data-slot="task-loading-title"]'),
|
||||
titleFont: font('[data-testid="task-chat-history-content"] [data-testid="issue-detail-header"] h2'),
|
||||
});
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
await page.goto(fixture.route);
|
||||
await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="false"]')).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Approve patch", exact: true })).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
const frames = await page.evaluate(() => (window as unknown as { taskLoadingFrames: Array<{ ready: boolean; headerVisible: boolean; loading: number[] | null; loadingComposer: number[] | null; composer: number[] | null; latestY?: number; scrollHeight?: number; loadingFont?: string; titleFont?: string }> }).taskLoadingFrames);
|
||||
await info.attach("loading-frames", { body: JSON.stringify(frames), contentType: "application/json" });
|
||||
expect(frames.filter(frame => !frame.ready && frame.headerVisible)).toEqual([]);
|
||||
const loading = frames.filter(frame => frame.loading);
|
||||
const ready = frames.filter(frame => frame.ready && frame.latestY !== undefined);
|
||||
expect(loading.length).toBeGreaterThan(1);
|
||||
expect(ready.length).toBeGreaterThan(5);
|
||||
for (let dimension = 0; dimension < 4; dimension++) {
|
||||
const values = loading.map(frame => frame.loading![dimension]);
|
||||
expect(Math.max(...values) - Math.min(...values)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(loading.at(-1)!.loadingComposer![dimension] - ready[0].composer![dimension])).toBeLessThanOrEqual(2);
|
||||
}
|
||||
expect(loading.at(-1)!.loadingFont).toBe(ready[0].titleFont);
|
||||
expect(Math.max(...ready.map(frame => frame.latestY!)) - Math.min(...ready.map(frame => frame.latestY!))).toBeLessThanOrEqual(2);
|
||||
expect(new Set(ready.map(frame => frame.scrollHeight)).size).toBe(1);
|
||||
} finally {
|
||||
await request.patch(`/api/companies/${fixture.company.id}`, { data: { status: "archived" } });
|
||||
}
|
||||
});
|
||||
|
||||
test("Classic startup does not guess a chat layout while personal preferences are pending", async ({ page, request }) => {
|
||||
const fixture = await createTask(request);
|
||||
let release!: () => void;
|
||||
const preferenceReady = new Promise<void>(resolve => { release = resolve; });
|
||||
try {
|
||||
await page.route("**/api/instance/settings/experimental", async route => {
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ json: { ...await response.json(), enableClassicTaskInterface: true, enableStreamlinedUi: false } });
|
||||
});
|
||||
await page.route("**/api/workspace-preferences/me", async route => {
|
||||
await preferenceReady;
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ json: { ...await response.json(), coordinatorWorkspaceEnabled: false } });
|
||||
});
|
||||
await page.goto(fixture.route);
|
||||
await expect(page.getByTestId("shell-configuration-loading")).toBeVisible();
|
||||
await expect(page.locator("[data-task-chat-shell]")).toHaveCount(0);
|
||||
await expect(page.getByTestId("issue-detail-header")).toHaveCount(0);
|
||||
release();
|
||||
await expect(page.getByTestId("issue-detail-header").getByRole("heading", { name: fixture.title, exact: true })).toBeVisible();
|
||||
await expect(page.locator("[data-task-chat-shell]")).toHaveCount(0);
|
||||
} finally {
|
||||
release();
|
||||
await request.patch(`/api/companies/${fixture.company.id}`, { data: { status: "archived" } });
|
||||
}
|
||||
});
|
||||
|
||||
test("a stalled web font does not hide an otherwise prepared conversation indefinitely", async ({ page, request }) => {
|
||||
const fixture = await createTask(request);
|
||||
let release!: () => void;
|
||||
const fontReady = new Promise<void>(resolve => { release = resolve; });
|
||||
try {
|
||||
await page.route("**/api/workspace-preferences/me", async route => {
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ json: { ...await response.json(), coordinatorWorkspaceEnabled: true } });
|
||||
});
|
||||
await page.route("**/*.woff2", async route => { await fontReady; await route.abort(); });
|
||||
await page.goto(fixture.route, { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByTestId("task-chat-loading-frame")).toBeVisible();
|
||||
await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="false"]')).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]')).toBeEditable();
|
||||
} finally {
|
||||
release();
|
||||
await page.unrouteAll({ behavior: "wait" });
|
||||
await request.patch(`/api/companies/${fixture.company.id}`, { data: { status: "archived" } });
|
||||
}
|
||||
});
|
||||
|
||||
for (const coordinator of [false, true]) test(`mobile chrome stays fixed when personal preferences resolve (Coordinator=${coordinator})`, async ({ page, request }, info) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const fixture = await createTask(request);
|
||||
let release!: () => void;
|
||||
const preferenceReady = new Promise<void>(resolve => { release = resolve; });
|
||||
await page.addInitScript(() => {
|
||||
const frames: number[][] = [];
|
||||
Object.assign(window, { mobileShellFrames: frames });
|
||||
const sample = () => {
|
||||
const shell = document.querySelector(".paperclip-app-shell");
|
||||
if (shell && shell.getBoundingClientRect().width > 0) {
|
||||
const rects = [".app-breadcrumb", ".mobile-bottom-nav", "#main-content"].map(selector => document.querySelector(selector)!.getBoundingClientRect());
|
||||
frames.push([innerWidth, innerHeight, scrollX, scrollY, ...rects.flatMap((rect, index) => index === 2 ? [rect.x, rect.y, rect.width] : [rect.x, rect.y, rect.width, rect.height])]);
|
||||
}
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
try {
|
||||
await page.route("**/api/instance/settings/experimental", async route => {
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ json: { ...await response.json(), enableClassicTaskInterface: true, enableStreamlinedUi: false } });
|
||||
});
|
||||
await page.route("**/api/workspace-preferences/me", async route => {
|
||||
await preferenceReady;
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ json: { ...await response.json(), coordinatorWorkspaceEnabled: coordinator } });
|
||||
});
|
||||
await page.goto(fixture.route);
|
||||
await expect(page.getByTestId("shell-configuration-loading")).toBeVisible();
|
||||
await expect(page.locator(".paperclip-app-shell")).toBeHidden();
|
||||
expect(await page.evaluate(() => (window as unknown as { mobileShellFrames: number[][] }).mobileShellFrames)).toEqual([]);
|
||||
release();
|
||||
if (coordinator) await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="false"]')).toBeVisible();
|
||||
else await expect(page.getByTestId("issue-detail-header").getByRole("heading", { name: fixture.title, exact: true })).toBeVisible();
|
||||
await page.waitForTimeout(300);
|
||||
const frames = await page.evaluate(() => (window as unknown as { mobileShellFrames: number[][] }).mobileShellFrames);
|
||||
await info.attach("mobile-preference-chrome", { body: JSON.stringify(frames), contentType: "application/json" });
|
||||
expect(frames.length).toBeGreaterThan(5);
|
||||
for (let dimension = 0; dimension < frames[0].length; dimension++) {
|
||||
const values = frames.map(frame => frame[dimension]);
|
||||
expect(Math.max(...values) - Math.min(...values)).toBeLessThanOrEqual(2);
|
||||
}
|
||||
} finally {
|
||||
release();
|
||||
await request.patch(`/api/companies/${fixture.company.id}`, { data: { status: "archived" } });
|
||||
}
|
||||
});
|
||||
|
||||
test("a seeded Classic loading title uses the resolved title typography", async ({ page, request }) => {
|
||||
const fixture = await createTask(request);
|
||||
let release!: () => void;
|
||||
const coreReady = new Promise<void>(resolve => { release = resolve; });
|
||||
try {
|
||||
await page.route("**/api/instance/settings/experimental", async route => {
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ json: { ...await response.json(), enableClassicTaskInterface: true, enableStreamlinedUi: false } });
|
||||
});
|
||||
await page.route("**/api/workspace-preferences/me", async route => {
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ json: { ...await response.json(), coordinatorWorkspaceEnabled: false } });
|
||||
});
|
||||
await page.goto(`/${fixture.company.issuePrefix}/issues`);
|
||||
await page.getByRole("link").filter({ hasText: fixture.title }).first().click();
|
||||
await expect(page.getByTestId("issue-detail-header").getByRole("heading", { name: fixture.title, exact: true })).toBeVisible();
|
||||
await page.route("**/api/issues/*", async route => { await coreReady; await route.continue(); });
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
const loadingTitle = page.getByRole("heading", { name: fixture.title, exact: true });
|
||||
await expect(loadingTitle).toBeVisible();
|
||||
await expect(page.getByTestId("issue-detail-header")).toHaveCount(0);
|
||||
const font = await loadingTitle.evaluate(element => getComputedStyle(element).font);
|
||||
release();
|
||||
const title = page.getByTestId("issue-detail-header").getByRole("heading", { name: fixture.title, exact: true });
|
||||
await expect(title).toBeVisible();
|
||||
expect(await title.evaluate(element => getComputedStyle(element).font)).toBe(font);
|
||||
} finally {
|
||||
release();
|
||||
await request.patch(`/api/companies/${fixture.company.id}`, { data: { status: "archived" } });
|
||||
}
|
||||
});
|
||||
@@ -52,6 +52,23 @@ include completed requests observed during the sample, even after content paint.
|
||||
|
||||
## Task layout stability
|
||||
|
||||
The normal browser CI suite also runs `tests/e2e/task-loading-frame.spec.ts`.
|
||||
It seeds a smaller API-only task and checks desktop/mobile frame and composer
|
||||
geometry, title typography, the initial reading position, Classic preference
|
||||
loading and typography, mobile shell handoff, and a stalled font request. It does not require a separate database URL.
|
||||
The rich 200-message fixture below remains an additional local stress journey.
|
||||
|
||||
`loading-layout.spec.ts` uses a retained synthetic task with a wrapping title,
|
||||
200 saved messages, document and PR cards, a monitor, and a pending confirmation.
|
||||
It records every animation frame during direct and list-based entry on desktop
|
||||
and mobile, including delayed web fonts and workspace preferences. It rejects real headers shown before
|
||||
the initial conversation is ready, loading-frame movement, different header
|
||||
typography, composer displacement, and movement of the initial reading position.
|
||||
The fixture requires the disposable PostgreSQL URL described below. Its agent
|
||||
has interval and on-demand wakes disabled, including when the retained fixture
|
||||
is reused. The suite does not start provider work.
|
||||
Videos, screenshots, and `frames.json` are retained with each result.
|
||||
|
||||
`compact-startup.spec.ts` keeps a synthetic local reproduction with 24 messages,
|
||||
a plan, three documents and three saved PR cards. It holds the enriched task
|
||||
response, records each visible frame's card count and geometry, and rejects
|
||||
|
||||
@@ -76,7 +76,8 @@ test("a delayed Coordinator preference cannot expand a cold task through Classic
|
||||
});
|
||||
try {
|
||||
await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}`);
|
||||
await expect(page.getByTestId("issue-detail-header")).toContainText("Review the latest investigation");
|
||||
await expect(page.getByTestId("shell-configuration-loading")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Review the latest investigation", exact: true })).not.toBeVisible();
|
||||
expect(historyRequests).toEqual([]);
|
||||
release();
|
||||
await ready(page);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { richLoadingFixture, type RichLoadingFixture } from "./rich-loading-fixture";
|
||||
|
||||
type Box = { x: number; y: number; width: number; height: number };
|
||||
type Font = { family: string; size: string; lineHeight: string; weight: string };
|
||||
type Frame = { time: number; ready: boolean; visibleHeader: boolean; loading: Box | null; latest: Box | null; scrollHeight?: number; loadingComposer: Box | null; composer: Box | null; loadingFont: Font | null; titleFont: Font | null; identifier: Box | null; identifierFont: Font | null };
|
||||
|
||||
test.use({ video: "on", trace: "retain-on-failure", serviceWorkers: "block", colorScheme: "dark" });
|
||||
test.setTimeout(60_000);
|
||||
test.skip(!process.env.PAPERCLIP_ISSUE_PERF_DATABASE_URL, "The rich loading fixture requires disposable PostgreSQL");
|
||||
|
||||
let fixture: RichLoadingFixture;
|
||||
test.beforeAll(async ({ request }) => { fixture = await richLoadingFixture(request); });
|
||||
|
||||
for (const entry of ["direct", "list", "delayed fonts", "delayed preferences"] as const) for (const mobile of [false, true]) test(`a rich task reveals once without flashing its header or moving its reading position (${entry}, ${mobile ? "mobile" : "desktop"})`, async ({ page }, info) => {
|
||||
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1280, height: 720 });
|
||||
await page.route("**/api/workspace-preferences/me", async route => {
|
||||
if (entry === "delayed preferences") await new Promise(resolve => setTimeout(resolve, 2500));
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ json: { ...await response.json(), coordinatorWorkspaceEnabled: true } });
|
||||
});
|
||||
if (entry === "delayed fonts") await page.route("**/*.woff2", async route => {
|
||||
await new Promise(resolve => setTimeout(resolve, 2500));
|
||||
await route.continue();
|
||||
});
|
||||
await page.addInitScript(() => {
|
||||
const frames: Frame[] = [];
|
||||
Object.assign(window, { loadingLayoutFrames: frames });
|
||||
const sample = () => {
|
||||
const loading = document.querySelector('[data-testid="task-chat-loading-frame"]');
|
||||
const ready = document.querySelector('[data-testid="task-chat-thread"] > [aria-busy="false"]');
|
||||
const scroller = document.querySelector('[data-testid="task-chat-scroller"]');
|
||||
const last = [...document.querySelectorAll('[id^="comment-"]')].at(-1);
|
||||
const identifier = [...document.querySelectorAll('[data-slot="task-title-identifier"]')].find(el => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0 && rect.top >= 0 && rect.bottom <= 76 && getComputedStyle(el).visibility === "visible";
|
||||
});
|
||||
const visibleHeader = [...document.querySelectorAll('[data-testid="issue-detail-header"]')].some(el => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return getComputedStyle(el).visibility === "visible" && rect.bottom > 60 && rect.top < innerHeight;
|
||||
});
|
||||
const box = (el: Element | null | undefined) => {
|
||||
if (!el || getComputedStyle(el).visibility !== "visible") return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return null;
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
};
|
||||
const font = (el: Element | null) => {
|
||||
if (!el) return null;
|
||||
const style = getComputedStyle(el);
|
||||
return { family: style.fontFamily, size: style.fontSize, lineHeight: style.lineHeight, weight: style.fontWeight };
|
||||
};
|
||||
frames.push({ time: performance.now(), ready: !!ready, visibleHeader, loading: box(loading), latest: box(last), scrollHeight: scroller?.scrollHeight,
|
||||
loadingComposer: box(document.querySelector('[data-testid="task-chat-loading-composer"] .paperclip-task-chat-composer')),
|
||||
composer: box(document.querySelector('[data-testid="task-chat-composer-stack"] .paperclip-task-chat-composer')),
|
||||
loadingFont: font(document.querySelector('[data-slot="task-loading-title"]')),
|
||||
titleFont: font(document.querySelector('[data-testid="task-chat-history-content"] [data-testid="issue-detail-header"] h2')),
|
||||
identifier: box(identifier), identifierFont: font(identifier ?? null),
|
||||
});
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
|
||||
await cdp.send("Network.enable");
|
||||
await cdp.send("Network.emulateNetworkConditions", { offline: false, latency: 80, downloadThroughput: 1179648, uploadThroughput: 196608 });
|
||||
if (entry === "list") {
|
||||
await page.goto(`/${fixture.prefix}/issues`);
|
||||
await page.locator("[data-inbox-issue-link]").filter({ hasText: fixture.title }).first().click();
|
||||
} else {
|
||||
await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}`);
|
||||
}
|
||||
await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="false"]')).toBeVisible();
|
||||
await expect(page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]')).toBeEditable();
|
||||
await expect(page.getByRole("button", { name: "Approve patch", exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Latest investigation findings", { exact: true })).toBeAttached();
|
||||
await page.waitForTimeout(500);
|
||||
const frames = await page.evaluate(() => (window as unknown as { loadingLayoutFrames: Frame[] }).loadingLayoutFrames);
|
||||
await fs.writeFile(info.outputPath("frames.json"), JSON.stringify({ frames }, null, 2));
|
||||
expect(frames.filter(frame => !frame.ready && frame.visibleHeader)).toEqual([]);
|
||||
const loadingFrames = frames.filter(frame => frame.loading);
|
||||
expect(loadingFrames.length).toBeGreaterThan(2);
|
||||
for (const dimension of ["x", "y", "width", "height"] as const) {
|
||||
const values = loadingFrames.map(frame => frame.loading![dimension]);
|
||||
expect(Math.max(...values) - Math.min(...values), `loading frame ${dimension}`).toBeLessThanOrEqual(2);
|
||||
}
|
||||
const visibleComposers = loadingFrames.filter(frame => frame.loadingComposer);
|
||||
expect(visibleComposers.length).toBeGreaterThan(2);
|
||||
for (const dimension of ["x", "y", "width", "height"] as const) {
|
||||
const values = visibleComposers.map(frame => frame.loadingComposer![dimension]);
|
||||
expect(Math.max(...values) - Math.min(...values), `loading composer ${dimension}`).toBeLessThanOrEqual(2);
|
||||
}
|
||||
const revealed = frames.filter(frame => frame.ready && frame.latest);
|
||||
expect(revealed.length).toBeGreaterThan(5);
|
||||
expect(Math.max(...revealed.map(frame => frame.latest!.y)) - Math.min(...revealed.map(frame => frame.latest!.y))).toBeLessThanOrEqual(2);
|
||||
expect(new Set(revealed.map(frame => frame.scrollHeight)).size).toBe(1);
|
||||
const lastLoading = loadingFrames.at(-1)!;
|
||||
expect(lastLoading.loadingFont).toEqual(revealed[0].titleFont);
|
||||
expect(Math.abs(lastLoading.loadingComposer!.y - revealed[0].composer!.y)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(lastLoading.loadingComposer!.height - revealed[0].composer!.height)).toBeLessThanOrEqual(2);
|
||||
const identifiers = frames.filter(frame => frame.identifier);
|
||||
expect(identifiers.length).toBeGreaterThan(2);
|
||||
expect(new Set(identifiers.map(frame => JSON.stringify(frame.identifierFont))).size).toBe(1);
|
||||
for (const dimension of ["x", "y", "width", "height"] as const) {
|
||||
const values = identifiers.map(frame => frame.identifier![dimension]);
|
||||
expect(Math.max(...values) - Math.min(...values), `breadcrumb identifier ${dimension}`).toBeLessThanOrEqual(2);
|
||||
}
|
||||
await page.screenshot({ path: info.outputPath("ready.png") });
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect, type APIRequestContext } from "@playwright/test";
|
||||
import { eq } from "../../../server/node_modules/drizzle-orm/index.js";
|
||||
|
||||
export interface RichLoadingFixture {
|
||||
companyId: string;
|
||||
prefix: string;
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
lastCommentId: string;
|
||||
interactionId: string;
|
||||
}
|
||||
|
||||
const fixturePath = path.resolve("test-results/rich-loading-fixture.json");
|
||||
|
||||
export async function richLoadingFixture(request: APIRequestContext): Promise<RichLoadingFixture> {
|
||||
const databaseUrl = process.env.PAPERCLIP_ISSUE_PERF_DATABASE_URL;
|
||||
if (!databaseUrl) throw new Error("The rich loading fixture requires a disposable PostgreSQL database");
|
||||
const target = new URL(databaseUrl);
|
||||
if (!["localhost", "127.0.0.1", "[::1]"].includes(target.hostname) || !target.pathname.endsWith("_test")) {
|
||||
throw new Error("Use a disposable loopback database ending in _test");
|
||||
}
|
||||
const saved = await fs.readFile(fixturePath, "utf8").then(JSON.parse).catch(error => {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
return null;
|
||||
});
|
||||
if (saved) {
|
||||
const response = await request.get(`/api/issues/${saved.id}`);
|
||||
if (response.ok()) {
|
||||
const issue = await response.json();
|
||||
expect(issue.companyId).toBe(saved.companyId);
|
||||
const agentResponse = await request.get(`/api/agents/${issue.assigneeAgentId}`);
|
||||
expect(agentResponse.ok(), await agentResponse.text()).toBeTruthy();
|
||||
const agent = await agentResponse.json();
|
||||
const heartbeat = agent.runtimeConfig?.heartbeat ?? {};
|
||||
if (heartbeat.enabled !== false || heartbeat.wakeOnDemand !== false) {
|
||||
const update = await request.patch(`/api/agents/${agent.id}`, { data: {
|
||||
runtimeConfig: { ...agent.runtimeConfig, heartbeat: { ...heartbeat, enabled: false, wakeOnDemand: false } },
|
||||
} });
|
||||
expect(update.ok(), await update.text()).toBeTruthy();
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
expect(response.status(), await response.text()).toBe(404);
|
||||
}
|
||||
const create = async (url: string, data: unknown) => {
|
||||
const response = await request.post(url, { data });
|
||||
expect(response.ok(), await response.text()).toBeTruthy();
|
||||
return response.json();
|
||||
};
|
||||
const company = await create("/api/companies", { name: "FrameLab" });
|
||||
const agent = await create(`/api/companies/${company.id}/agents`, {
|
||||
name: "Investigation author", role: "engineer", status: "idle", adapterType: "claude_local", adapterConfig: {},
|
||||
runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false } },
|
||||
});
|
||||
const project = await create(`/api/companies/${company.id}/projects`, { name: "Loading verification" });
|
||||
const title = "[Investigation] Failed query selecting a saved task record from a long-running workspace investigation after restoring the archived state (workspace-investigation.ts)";
|
||||
const issue = await create(`/api/companies/${company.id}/issues`, {
|
||||
title, status: "backlog", projectId: project.id,
|
||||
description: "## Investigate the saved workspace\n\n" +
|
||||
"Keep the current task, its saved findings, and the requested review together. ".repeat(12) +
|
||||
"\n\n- Inspect the original report.\n- Check the saved evidence.\n- Retain the complete conversation for the reviewer.\n\n" +
|
||||
"The investigation contains a long thread, rich document cards, a scheduled monitor, and a pending decision.",
|
||||
});
|
||||
for (const [key, label] of [["plan", "Investigation plan"], ["analysis", "Saved analysis"], ["verification", "Verification results"]]) {
|
||||
const response = await request.put(`/api/issues/${issue.id}/documents/${key}`, { data: {
|
||||
title: label, format: "markdown", body: `# ${label}\n\n${"Preserve the saved investigation and verify the expected behavior. ".repeat(20)}`,
|
||||
} });
|
||||
expect(response.ok(), await response.text()).toBeTruthy();
|
||||
}
|
||||
await create(`/api/issues/${issue.id}/work-products`, {
|
||||
type: "pull_request", provider: "github", title: "Saved investigation patch", status: "active",
|
||||
url: "https://github.com/example/layout-fixture/pull/42", metadata: { repository: "example/layout-fixture", number: 42 },
|
||||
});
|
||||
const interaction = await create(`/api/issues/${issue.id}/interactions`, {
|
||||
kind: "request_confirmation", title: "Approve the investigation patch", continuationPolicy: "none",
|
||||
payload: { version: 1, prompt: "Review the saved investigation and approve the proposed patch?", acceptLabel: "Approve patch", rejectLabel: "Request changes", detailsMarkdown: "The saved plan and verification results are available in this task." },
|
||||
});
|
||||
const { createDb, closeRegisteredClients, issues, issueComments } = await import("../../../packages/db/src/index");
|
||||
const db = createDb(databaseUrl);
|
||||
const base = Date.now() - 30 * 24 * 3600_000;
|
||||
const comments = Array.from({ length: 200 }, (_, index) => ({
|
||||
id: randomUUID(), companyId: company.id, issueId: issue.id, authorType: "agent", authorAgentId: agent.id,
|
||||
body: `## ${index === 199 ? "Latest investigation findings" : `Investigation update ${index}`}\n\n` +
|
||||
"Verified the saved workspace, inspected the reported behavior, and checked that the evidence still matches the current revision. ".repeat(index === 199 ? 12 : 5) +
|
||||
(index === 199 ? "\n\n[Read the saved analysis](#document-analysis) and [verification results](#document-verification).\n\n| Check | Result |\n|---|---|\n| Saved evidence | Complete |\n| Review | Waiting for your decision |" : ""),
|
||||
createdAt: new Date(base + index * 60_000), updatedAt: new Date(base + index * 60_000),
|
||||
}));
|
||||
const nextCheckAt = new Date(Date.now() + 2 * 24 * 3600_000);
|
||||
try {
|
||||
await db.insert(issueComments).values(comments);
|
||||
await db.update(issues).set({
|
||||
assigneeAgentId: agent.id, status: "in_review", monitorNextCheckAt: nextCheckAt,
|
||||
monitorAttemptCount: 12, monitorNotes: "Watching the investigation patch", monitorScheduledBy: "assignee",
|
||||
executionPolicy: { mode: "normal", commentRequired: true, stages: [], monitor: {
|
||||
nextCheckAt: nextCheckAt.toISOString(), serviceName: "Review", externalRef: "Investigation patch", scheduledBy: "assignee",
|
||||
} },
|
||||
}).where(eq(issues.id, issue.id));
|
||||
} finally { await closeRegisteredClients(databaseUrl); }
|
||||
const fixture = { companyId: company.id, prefix: company.issuePrefix, id: issue.id, identifier: issue.identifier, title, lastCommentId: comments.at(-1)!.id, interactionId: interaction.id };
|
||||
await fs.mkdir(path.dirname(fixturePath), { recursive: true });
|
||||
await fs.writeFile(fixturePath, JSON.stringify(fixture, null, 2));
|
||||
return fixture;
|
||||
}
|
||||
@@ -119,7 +119,7 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
|
||||
{currentCrumb.leading ? (
|
||||
<span className="flex shrink-0 items-center self-center">{currentCrumb.leading}</span>
|
||||
) : null}
|
||||
<span className="min-w-0 truncate" title={currentCrumb.label}>{currentCrumb.label}</span>
|
||||
<span className="min-w-0 flex-1 truncate" title={currentCrumb.label}>{currentCrumb.label}</span>
|
||||
<CrumbIdentifier identifier={currentCrumb.identifier} />
|
||||
</h1>
|
||||
{globalToolbarSlots}
|
||||
@@ -133,18 +133,19 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
{breadcrumbs.map((crumb, i) => {
|
||||
const isLast = i === breadcrumbs.length - 1;
|
||||
const pinnedIdentifier = taskDetailLayout && isLast && Boolean(crumb.identifier);
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <BreadcrumbSeparator />}
|
||||
<BreadcrumbItem className={isLast ? "min-w-0" : "shrink-0"}>
|
||||
<BreadcrumbItem className={cn(isLast ? "min-w-0" : "shrink-0", pinnedIdentifier && "flex-1")}>
|
||||
{isLast || !crumb.href ? (
|
||||
crumb.leading || crumb.identifier ? (
|
||||
<BreadcrumbPage className={cn("flex min-w-0 gap-1.5", crumb.trailing ? "items-center" : "items-baseline")}>
|
||||
<BreadcrumbPage className={cn("flex min-w-0 gap-1.5", crumb.trailing ? "items-center" : "items-baseline", pinnedIdentifier && "flex-1")}>
|
||||
{crumb.leading && (
|
||||
<span className="flex shrink-0 items-center self-center">{crumb.leading}</span>
|
||||
)}
|
||||
{!taskDetailLayout ? <CrumbIdentifier identifier={crumb.identifier} /> : null}
|
||||
<span className="min-w-0 truncate">{crumb.label}</span>
|
||||
<span className={cn("min-w-0 truncate", pinnedIdentifier && "flex-1")}>{crumb.label}</span>
|
||||
{taskDetailLayout && isLast ? <CrumbIdentifier identifier={crumb.identifier} /> : null}
|
||||
</BreadcrumbPage>
|
||||
) : (
|
||||
|
||||
@@ -26,7 +26,7 @@ it.each([
|
||||
["/PAP/issues", false],
|
||||
["/PAP/chats/agent", false],
|
||||
["/PAP/apps", false],
|
||||
])("permits only a guarded task detail while the personal preference loads: %s", async (path, permitted) => {
|
||||
])("prepares only guarded task data behind the loading view while preferences load: %s", async (path, permitted) => {
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
@@ -35,7 +35,11 @@ it.each([
|
||||
<Route path="*" element={<div>Guarded task content</div>} />
|
||||
</Route>
|
||||
</Routes></MemoryRouter>));
|
||||
expect(container.textContent).toBe(permitted ? "Guarded task content" : "Loading shell");
|
||||
expect(container.textContent).toContain("Loading shell");
|
||||
expect(container.textContent?.includes("Guarded task content")).toBe(permitted);
|
||||
const prepared = container.querySelector<HTMLElement>('[data-testid="shell-prepared-outlet"]');
|
||||
expect(prepared?.hidden ?? null).toBe(permitted ? true : null);
|
||||
if (prepared) expect(prepared.hasAttribute("inert")).toBe(true);
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
|
||||
@@ -72,3 +76,27 @@ it.each([false, true])("runs access checks while shell settings are pending (mem
|
||||
client.clear();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps prepared task content mounted without taking existing focus when the shell becomes visible", async () => {
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
const input = document.createElement("input");
|
||||
document.body.append(container, input);
|
||||
const view = (loaded: boolean) => <MemoryRouter initialEntries={["/PAP/issues/PAP-1"]}><Routes>
|
||||
<Route element={<ShellConfigurationGate loaded={loaded} allowPendingTaskDetail />}>
|
||||
<Route path="*" element={<main id="main-content" tabIndex={-1} data-testid="prepared-task">Task data</main>} />
|
||||
</Route>
|
||||
</Routes></MemoryRouter>;
|
||||
try {
|
||||
await act(async () => root.render(view(false)));
|
||||
const task = container.querySelector('[data-testid="prepared-task"]');
|
||||
expect(task).not.toBeNull();
|
||||
input.focus();
|
||||
await act(async () => root.render(view(true)));
|
||||
await act(async () => { await new Promise(resolve => requestAnimationFrame(resolve)); });
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(container.querySelector('[data-testid="prepared-task"]')).toBe(task);
|
||||
expect(container.querySelector<HTMLElement>('[data-testid="shell-prepared-outlet"]')?.hidden).toBe(false);
|
||||
expect(container.querySelector('[data-testid="shell-configuration-loading"]')).toBeNull();
|
||||
} finally { await act(async () => root.unmount()); container.remove(); input.remove(); }
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { scheduleMainContentFocus } from "@/lib/main-content-focus";
|
||||
import { Outlet, useLocation } from "@/lib/router";
|
||||
import { PaperclipLoading } from "./AnimatedPaperclipIcon";
|
||||
|
||||
@@ -7,7 +9,15 @@ export function ShellConfigurationGate({ loaded, allowPendingTaskDetail = false
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const isTaskDetail = segments.length === 3 && segments[1] === "issues"
|
||||
&& !["all", "active", "backlog", "done", "recent"].includes(segments[2]!);
|
||||
// Task detail owns a second presentation gate. It can show its account-bound
|
||||
// title editor while identity loads, without exposing variant-specific tools.
|
||||
return loaded || (allowPendingTaskDetail && isTaskDetail) ? <Outlet /> : <PaperclipLoading />;
|
||||
useLayoutEffect(() => {
|
||||
if (!loaded) return;
|
||||
const active = document.activeElement;
|
||||
if (active && active !== document.body && active !== document.documentElement) return;
|
||||
return scheduleMainContentFocus(document.getElementById("main-content"));
|
||||
}, [loaded]);
|
||||
const prepareOutlet = loaded || (allowPendingTaskDetail && isTaskDetail);
|
||||
return <>
|
||||
{!loaded ? <div data-testid="shell-configuration-loading"><PaperclipLoading /></div> : null}
|
||||
{prepareOutlet ? <div hidden={!loaded} inert={!loaded} aria-hidden={!loaded || undefined} className={loaded ? "contents" : "hidden"} data-testid="shell-prepared-outlet"><Outlet /></div> : null}
|
||||
</>;
|
||||
}
|
||||
@@ -376,13 +376,15 @@ it.each([true, false])("defers hidden content until history is ready and keeps i
|
||||
|
||||
it.each([true, false])("coordinates compact cards before first reveal and retains content during refresh (mobile=%s)", async (mobile) => {
|
||||
sidebarState.isMobile = mobile;
|
||||
const props = { issueId: "cold-issue", compactHistory: true, comments: createLongThreadComments(), historyControl: <button>More messages</button>, onAdd: async () => {} };
|
||||
const props = { issueId: "cold-issue", compactHistory: true, comments: createLongThreadComments(), threadHeader: <h2>Saved task title</h2>, historyControl: <button>More messages</button>, onAdd: async () => {} };
|
||||
render(<TaskChatThread {...props} initialHistoryPending />);
|
||||
expect(container.querySelector('[data-testid="task-chat-history-loading"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-history-loading"]')?.textContent).not.toContain("Saved task title");
|
||||
render(<TaskChatThread {...props} initialHistoryPending={false} />);
|
||||
await settleThreadLayout();
|
||||
expect(container.querySelector('[data-testid="task-chat-history-loading"]')).toBeNull();
|
||||
expect(container.textContent).toContain("Thread message 1");
|
||||
expect(container.textContent).toContain("Saved task title");
|
||||
expect(container.textContent).toContain("More messages");
|
||||
const editor = container.querySelector('[data-testid="mock-editor"]');
|
||||
expect(editor).not.toBeNull();
|
||||
@@ -391,6 +393,26 @@ it.each([true, false])("coordinates compact cards before first reveal and retain
|
||||
expect(container.querySelector('[data-testid="mock-editor"]')).toBe(editor);
|
||||
});
|
||||
|
||||
it.each([false, true])("waits for fonts but bounds a stalled request (stalled=%s)", async (stalled) => {
|
||||
const original = Object.getOwnPropertyDescriptor(document, "fonts");
|
||||
const fonts = { status: "loading" };
|
||||
Object.defineProperty(document, "fonts", { configurable: true, value: fonts });
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
render(<TaskChatThread issueId="font-readiness" comments={createLongThreadComments()} onAdd={async () => {}} />);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(200); });
|
||||
expect(container.querySelector('[data-testid="task-chat-history-loading"]')).not.toBeNull();
|
||||
if (!stalled) fonts.status = "loaded";
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(stalled ? 3100 : 100); });
|
||||
expect(container.querySelector('[data-testid="task-chat-history-loading"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-thread"] > [aria-busy="false"]')).not.toBeNull();
|
||||
} finally {
|
||||
if (original) Object.defineProperty(document, "fonts", original);
|
||||
else Reflect.deleteProperty(document, "fonts");
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps revealed history scrollable while metadata for another page is pending", async () => {
|
||||
function Readiness() { return <span data-testid="history-scroll-ready">{String(useTaskChatScrollNavigation().ready)}</span>; }
|
||||
const props = { issueId: "paged-chat", compactHistory: true, comments: createLongThreadComments(), onAdd: async () => {}, threadHeader: <Readiness /> };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ScrollingHistory } from "@/components/task-chat/TaskMessageScroller";
|
||||
import { TaskChatShimmerScope } from "@/components/task-chat/TaskChatShimmer";
|
||||
import { useRunnerSubmission } from "@/hooks/useRunnerSubmission";
|
||||
import { TaskChatLoadingShell } from "@/components/task-chat/TaskChatLoadingShell";
|
||||
import {
|
||||
ISSUE_DETAIL_CONTENT_PAINT_MARK,
|
||||
ISSUE_DETAIL_CONTENT_MEASURE,
|
||||
@@ -121,7 +122,6 @@ import { useSidebar } from "@/context/SidebarContext";
|
||||
import { useStreamlinedUiEnabled } from "@/hooks/useStreamlinedUiEnabled";
|
||||
import { useDismissedTaskQuestions } from "@/hooks/useDismissedTaskQuestions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
|
||||
import { isRedundantAiRecoveryNotice, latestSameRunHandoffTimestamp } from "@/lib/issue-chat-messages";
|
||||
@@ -260,6 +260,7 @@ function isRunnerResponseComment(params: {
|
||||
// well within this as soon as the settled turn/comment lands.
|
||||
const SETTLING_TAIL_MAX_MS = 15_000;
|
||||
const INITIAL_HISTORY_REVEAL_TIMEOUT_MS = 15_000;
|
||||
const INITIAL_FONT_REVEAL_TIMEOUT_MS = 3000;
|
||||
const EMPTY_LIVE_ISSUE_IDS: ReadonlySet<string> = new Set<string>();
|
||||
const LONG_THREAD_BLOCKER_REPEAT_COUNT = 4;
|
||||
|
||||
@@ -3168,13 +3169,20 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// Readiness is latched per issue: refetches never hide existing conversation.
|
||||
useEffect(() => {
|
||||
if (historyRevealed || historyPending) return;
|
||||
let previousWidth: number | undefined;
|
||||
const fontWaitStarted = performance.now();
|
||||
let previousGeometry: string | undefined;
|
||||
let stableFrames = 0;
|
||||
let frame: number;
|
||||
const revealWhenSettled = () => {
|
||||
const width = historyShellRef.current?.getBoundingClientRect().width;
|
||||
stableFrames = width === previousWidth ? stableFrames + 1 : 0;
|
||||
previousWidth = width;
|
||||
const shell = historyShellRef.current;
|
||||
const bounds = shell?.getBoundingClientRect();
|
||||
const scroller = shell?.querySelector('[data-testid="task-chat-scroller"]');
|
||||
const composer = shell?.querySelector('[data-testid="task-chat-composer-dock"]');
|
||||
const geometry = JSON.stringify([bounds?.x, bounds?.y, bounds?.width, bounds?.height,
|
||||
scroller?.clientHeight, scroller?.scrollHeight, scroller?.scrollTop, composer?.getBoundingClientRect().height]);
|
||||
const fontsPending = document.fonts?.status === "loading" && performance.now() - fontWaitStarted < INITIAL_FONT_REVEAL_TIMEOUT_MS;
|
||||
stableFrames = geometry === previousGeometry && !fontsPending ? stableFrames + 1 : 0;
|
||||
previousGeometry = geometry;
|
||||
if (stableFrames >= 2) setRevealedHistoryKey(historyKey);
|
||||
else frame = requestAnimationFrame(revealWhenSettled);
|
||||
};
|
||||
@@ -3201,12 +3209,17 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col",
|
||||
"relative flex min-h-0 flex-1 flex-col",
|
||||
isMobile && !historyRevealed && "task-chat-history-pending",
|
||||
)}
|
||||
data-testid="task-chat-thread"
|
||||
ref={historyShellRef}
|
||||
>
|
||||
{!historyRevealed ? (
|
||||
<div className="absolute inset-0 z-10 overflow-hidden bg-background" data-testid="task-chat-history-loading">
|
||||
<TaskChatLoadingShell mobile={isMobile} streamlined={streamlinedUiEnabled} coordinator={coordinatorEnabled} />
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex min-h-0 flex-1 flex-col",
|
||||
@@ -3226,21 +3239,6 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!historyRevealed ? (
|
||||
<div
|
||||
className="absolute inset-0 z-10 overflow-hidden bg-background"
|
||||
data-testid="task-chat-history-loading"
|
||||
role="status"
|
||||
aria-label="Loading conversation"
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-4 px-4 py-3">
|
||||
{threadHeader}
|
||||
<Skeleton className="h-16 w-3/4 animate-none" />
|
||||
<Skeleton className="h-24 w-4/5 self-end animate-none" />
|
||||
<Skeleton className="h-16 w-3/4 animate-none" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
data-testid="task-chat-history-content"
|
||||
className={cn(
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { taskChatComposerSurfaceClasses } from "./composer-surface";
|
||||
import { useComposerStop } from "@/hooks/useComposerStop";
|
||||
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
|
||||
import {
|
||||
@@ -1347,12 +1348,7 @@ export function TaskChatComposer({
|
||||
</TaskChatComposerBar>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
streamlined
|
||||
? "paperclip-task-chat-composer rounded-(--radius-task-composer) border border-border bg-card p-(--sz-18px) shadow-(--shadow-task-composer) dark:border-0 dark:bg-muted dark:shadow-none"
|
||||
: "paperclip-task-chat-composer rounded-xl bg-card p-(--sz-18px)",
|
||||
mobile && "p-3",
|
||||
)}
|
||||
className={taskChatComposerSurfaceClasses(mobile, streamlined)}
|
||||
onKeyDownCapture={(e) => {
|
||||
// Capture mode shortcuts on the wrapper so they work while the rich
|
||||
// editor is focused and win over Lexical/browser bindings. Match the
|
||||
|
||||
@@ -43,6 +43,25 @@ describe("TaskChatComposerDock", () => {
|
||||
return { dock, scroller, scrollBy, onTranscriptWheel };
|
||||
}
|
||||
|
||||
it("keeps a loading placeholder from replacing the measured composer height", () => {
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
|
||||
return { height: this.getAttribute("data-testid") === "task-chat-composer-dock" ? 140 : 72 } as DOMRect;
|
||||
});
|
||||
flushSync(() => root.render(
|
||||
<div className="coordinator-workspace">
|
||||
<div data-testid="task-chat-thread">
|
||||
<TaskChatComposerDock mobile={false} streamlined><textarea /></TaskChatComposerDock>
|
||||
<TaskChatComposerDock mobile={false} streamlined placeholder><div>Loading</div></TaskChatComposerDock>
|
||||
</div>
|
||||
</div>,
|
||||
));
|
||||
const thread = container.querySelector<HTMLElement>('[data-testid="task-chat-thread"]')!;
|
||||
const placeholder = container.querySelector('[data-testid="task-chat-loading-composer"]')!;
|
||||
expect(thread.style.getPropertyValue("--task-composer-dock-height")).toBe("140px");
|
||||
expect(placeholder.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(placeholder.hasAttribute("inert")).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[WheelEvent.DOM_DELTA_PIXEL, 30],
|
||||
[WheelEvent.DOM_DELTA_LINE, 720],
|
||||
|
||||
@@ -6,11 +6,13 @@ interface TaskChatComposerDockProps {
|
||||
mobile: boolean;
|
||||
streamlined: boolean;
|
||||
concealed?: boolean;
|
||||
placeholder?: boolean;
|
||||
}
|
||||
|
||||
export function TaskChatComposerDock({ children, mobile, streamlined, concealed = false }: TaskChatComposerDockProps) {
|
||||
export function TaskChatComposerDock({ children, mobile, streamlined, concealed = false, placeholder = false }: TaskChatComposerDockProps) {
|
||||
const dockRef = useRef<HTMLDivElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
if (placeholder) return;
|
||||
const dock = dockRef.current;
|
||||
const thread = dock?.closest<HTMLElement>('[data-testid="task-chat-thread"]');
|
||||
if (!dock || !thread || !thread.closest(".coordinator-workspace")) return;
|
||||
@@ -42,15 +44,15 @@ export function TaskChatComposerDock({ children, mobile, streamlined, concealed
|
||||
observer?.disconnect();
|
||||
thread.style.removeProperty("--task-composer-dock-height");
|
||||
};
|
||||
}, []);
|
||||
}, [placeholder]);
|
||||
return <div
|
||||
ref={dockRef}
|
||||
data-testid="task-chat-composer-dock"
|
||||
data-testid={placeholder ? "task-chat-loading-composer" : "task-chat-composer-dock"}
|
||||
data-conversation-typography=""
|
||||
inert={concealed}
|
||||
aria-hidden={concealed || undefined}
|
||||
inert={concealed || placeholder}
|
||||
aria-hidden={concealed || placeholder || undefined}
|
||||
className={cn(
|
||||
"flex max-w-(--tc-shell-max-w) shrink-0 flex-col gap-2",
|
||||
"task-chat-composer-dock flex max-w-(--tc-shell-max-w) shrink-0 flex-col gap-2",
|
||||
concealed && "invisible",
|
||||
mobile
|
||||
? cn("w-auto px-1 pb-1", streamlined ? "-mx-2" : "mx-2")
|
||||
|
||||
@@ -22,7 +22,7 @@ function customPropertyValue(name: string): string {
|
||||
|
||||
describe("task-chat composer styles", () => {
|
||||
it("covers transcript text and blocks clicks across the Coordinator footer", () => {
|
||||
const block = cssBlock('.coordinator-workspace [data-testid="task-chat-composer-dock"]');
|
||||
const block = cssBlock('.coordinator-workspace .task-chat-composer-dock');
|
||||
|
||||
expect(block).toContain("background: var(--background)");
|
||||
expect(block).toContain("pointer-events: auto");
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TaskChatComposerDock } from "./TaskChatComposerDock";
|
||||
import { taskChatComposerSurfaceClasses } from "./composer-surface";
|
||||
|
||||
export function TaskChatLoadingShell({ mobile, streamlined, coordinator }: {
|
||||
mobile: boolean;
|
||||
streamlined: boolean;
|
||||
coordinator: boolean;
|
||||
}) {
|
||||
return <div
|
||||
data-testid="task-chat-loading-frame"
|
||||
role="status"
|
||||
aria-label="Loading conversation"
|
||||
className="task-chat-loading-shell relative flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden bg-background"
|
||||
>
|
||||
<div aria-hidden="true" className={cn(
|
||||
"mx-auto flex min-h-0 w-full max-w-(--tc-shell-max-w) flex-1 flex-col gap-6 overflow-hidden px-4 pt-3",
|
||||
streamlined && "md:px-0",
|
||||
)}>
|
||||
<div className="space-y-3">
|
||||
<div data-slot="task-loading-title" className={cn("space-y-3", coordinator ? "text-2xl font-normal" : streamlined ? "text-xl font-semibold" : "text-base font-semibold", "leading-normal")}>
|
||||
<Skeleton className="h-6 w-4/5" />
|
||||
<Skeleton className="h-6 w-3/5" />
|
||||
</div>
|
||||
<div className="flex h-6 items-center gap-3">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-6 w-6 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-16 w-3/4 shrink-0" />
|
||||
<Skeleton className="h-24 w-4/5 shrink-0 self-end" />
|
||||
<Skeleton className="h-16 w-3/4 shrink-0" />
|
||||
</div>
|
||||
<TaskChatComposerDock mobile={mobile} streamlined={streamlined} placeholder>
|
||||
<div aria-hidden="true" className={taskChatComposerSurfaceClasses(mobile, streamlined)}>
|
||||
<div className="flex min-h-(--sz-48px) items-start px-1 py-1">
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
<div className="mt-2 flex h-8 items-center gap-2">
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="ml-auto h-8 w-8 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</TaskChatComposerDock>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function taskChatComposerSurfaceClasses(mobile: boolean, streamlined: boolean) {
|
||||
return cn(
|
||||
streamlined
|
||||
? "paperclip-task-chat-composer rounded-(--radius-task-composer) border border-border bg-card p-(--sz-18px) shadow-(--shadow-task-composer) dark:border-0 dark:bg-muted dark:shadow-none"
|
||||
: "paperclip-task-chat-composer rounded-xl bg-card p-(--sz-18px)",
|
||||
mobile && "p-3",
|
||||
);
|
||||
}
|
||||
+3
-3
@@ -5045,7 +5045,7 @@ html:not(.dark) .coordinator-sidebar {
|
||||
.coordinator-workspace [data-conversation-text="human"] { background: var(--bubble-human-bg); color: var(--bubble-human-fg); }
|
||||
.coordinator-workspace [data-question-answer] { background: var(--card); }
|
||||
.dark .coordinator-workspace [data-question-answer] { background: var(--muted); }
|
||||
.coordinator-workspace [data-testid="task-chat-composer-dock"] {
|
||||
.coordinator-workspace .task-chat-composer-dock {
|
||||
flex-shrink: 0;
|
||||
background: var(--background);
|
||||
backdrop-filter: none;
|
||||
@@ -5053,7 +5053,7 @@ html:not(.dark) .coordinator-sidebar {
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.coordinator-workspace [data-testid="task-chat-thread"] { position: relative; }
|
||||
.coordinator-workspace [data-testid="task-chat-composer-dock"] { position: absolute; inset-inline: 0; bottom: 0; }
|
||||
.coordinator-workspace .task-chat-composer-dock { position: absolute; inset-inline: 0; bottom: 0; }
|
||||
.coordinator-workspace [data-testid="task-chat-scroller"] > .paperclip-mobile-thread {
|
||||
padding-bottom: calc(var(--task-composer-dock-height, 0px) + var(--spacing) * 4);
|
||||
}
|
||||
@@ -5061,7 +5061,7 @@ html:not(.dark) .coordinator-sidebar {
|
||||
bottom: calc(var(--task-composer-dock-height, 0px) + var(--spacing) * 3);
|
||||
}
|
||||
}
|
||||
.coordinator-workspace [data-testid="task-chat-composer-dock"] > :last-child { border-radius: var(--coordinator-card-radius); }
|
||||
.coordinator-workspace .task-chat-composer-dock > :last-child { border-radius: var(--coordinator-card-radius); }
|
||||
.coordinator-workspace [cmdk-root] { height: auto; flex: none; }
|
||||
.coordinator-workspace [cmdk-list] { flex: none; }
|
||||
.coordinator-decision-card { border: 1px solid var(--border); border-radius: var(--coordinator-card-radius, var(--radius-xl)); padding: var(--coordinator-card-inset, calc(var(--spacing) * 6)); background: var(--card); box-shadow: var(--coordinator-card-shadow); }
|
||||
|
||||
@@ -179,6 +179,7 @@ import { MetricCard } from "@/components/MetricCard";
|
||||
import { FilterBar, type FilterValue } from "@/components/FilterBar";
|
||||
import { InlineEditor } from "@/components/InlineEditor";
|
||||
import { PageSkeleton } from "@/components/PageSkeleton";
|
||||
import { TaskChatLoadingShell } from "@/components/task-chat/TaskChatLoadingShell";
|
||||
import { Identity } from "@/components/Identity";
|
||||
import { AppLogo } from "@/pages/apps/AppLogo";
|
||||
import { IssueReferencePill } from "@/components/IssueReferencePill";
|
||||
@@ -1902,6 +1903,16 @@ export function DesignGuide() {
|
||||
<PageSkeleton variant="detail" />
|
||||
</div>
|
||||
</SubSection>
|
||||
<SubSection title="Task conversation loading">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="relative h-96 overflow-hidden rounded-md border border-border">
|
||||
<TaskChatLoadingShell mobile={false} streamlined coordinator={false} />
|
||||
</div>
|
||||
<div className="coordinator-workspace relative h-96 overflow-hidden rounded-md border border-border">
|
||||
<TaskChatLoadingShell mobile streamlined coordinator />
|
||||
</div>
|
||||
</div>
|
||||
</SubSection>
|
||||
</Section>
|
||||
|
||||
{/* ============================================================ */}
|
||||
|
||||
@@ -1562,7 +1562,10 @@ describe("IssueDetail", () => {
|
||||
await act(async () => renderIssueView());
|
||||
await flushReact();
|
||||
expect(mockIssueChatThreadRender).not.toHaveBeenCalled();
|
||||
expect(container.querySelector('[data-testid="issue-detail-header"]')?.textContent).toContain(createIssue().title);
|
||||
expect(container.querySelector('[data-testid="task-detail-presentation-loading"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-task-chat-shell]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="issue-detail-header"]')).toBeNull();
|
||||
expect(mockOpenPanel).not.toHaveBeenCalled();
|
||||
coordinatorPreference.loaded = true;
|
||||
await act(async () => renderIssueView());
|
||||
await waitForAssertion(() => expect(mockIssueChatThreadRender).toHaveBeenCalled());
|
||||
@@ -1643,6 +1646,8 @@ describe("IssueDetail", () => {
|
||||
expect(mockIssuesApi.listComments).not.toHaveBeenCalled();
|
||||
expect(mockActivityApi.forIssue).not.toHaveBeenCalled();
|
||||
expect(mockActivityApi.runsForIssue).not.toHaveBeenCalled();
|
||||
expect(container.querySelector('[data-task-chat-shell]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-detail-presentation-loading"]')).not.toBeNull();
|
||||
|
||||
Object.assign(coordinatorPreference, { enabled: true, loaded: true });
|
||||
await act(async () => renderIssueView());
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useIssueIdBatches } from "@/hooks/useIssueIdBatches";
|
||||
import { TaskChatLoadingShell } from "@/components/task-chat/TaskChatLoadingShell";
|
||||
import { useConversationHistoryPages, type ConversationHistoryPage } from "@/hooks/useConversationHistoryPages";
|
||||
import { taskChatHashAnchor } from "@/components/task-chat/scroll-navigation";
|
||||
import { AgentChatReadProvider } from "@/hooks/useAgentChatRead";
|
||||
@@ -936,31 +937,6 @@ function ChatBubbleSkeleton({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Composer placeholder mirroring TaskChatComposer's docked card (a bordered
|
||||
* rounded input area with a plus, a mode chip, and a send affordance) so the
|
||||
* foot of the loading state matches the real chat shell.
|
||||
*/
|
||||
function IssueChatComposerSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn("rounded-xl border border-input bg-card p-2", className)}
|
||||
data-testid="issue-chat-composer-skeleton"
|
||||
>
|
||||
<div className="min-h-(--sz-48px) space-y-2 px-1 py-1">
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
<Skeleton className="h-3 w-1/3" />
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<Skeleton className="h-8 w-24 rounded-md" />
|
||||
<div className="flex-1" />
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternating chat-bubble placeholders for the thread body. Widths and heights
|
||||
* vary so the skeleton mirrors a real back-and-forth (TaskChatThreadView)
|
||||
@@ -1001,24 +977,27 @@ function useTaskDetailInterfaceMode(conversationMode = false) {
|
||||
|
||||
export function IssueDetailLoadingState({
|
||||
headerSeed,
|
||||
titleEditor,
|
||||
}: {
|
||||
headerSeed: ReturnType<typeof readIssueDetailHeaderSeed>;
|
||||
titleEditor?: React.ReactNode;
|
||||
}) {
|
||||
const identifier =
|
||||
headerSeed?.identifier ?? headerSeed?.id.slice(0, 8) ?? null;
|
||||
const { taskChatShellEnabled } = useTaskDetailInterfaceMode();
|
||||
const { taskChatShellEnabled, streamlinedUiEnabled, coordinatorWorkspaceEnabled, loaded } = useTaskDetailInterfaceMode();
|
||||
const { isMobile } = useSidebar();
|
||||
if (!loaded) {
|
||||
return <div data-testid="task-detail-presentation-loading" role="status" aria-label="Loading conversation" className="flex h-full min-h-0 w-full flex-1 items-center justify-center">
|
||||
<Skeleton aria-hidden="true" className="h-4 w-24" />
|
||||
</div>;
|
||||
}
|
||||
if (taskChatShellEnabled) {
|
||||
return <div data-task-chat-shell="" className="flex h-full min-h-0 w-full flex-1 flex-col">
|
||||
<TaskChatLoadingShell mobile={isMobile} streamlined={streamlinedUiEnabled} coordinator={coordinatorWorkspaceEnabled} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
taskChatShellEnabled
|
||||
? "task-chat-loading-shell mx-auto flex min-h-0 w-full max-w-(--tc-shell-max-w) flex-1 flex-col gap-6"
|
||||
: "max-w-3xl space-y-6"
|
||||
}
|
||||
>
|
||||
<div className="space-y-3" data-testid={titleEditor ? "issue-detail-header" : undefined}>
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-3 w-40" />
|
||||
|
||||
<div className="flex items-center gap-2 min-w-0 flex-wrap">
|
||||
@@ -1076,9 +1055,9 @@ export function IssueDetailLoadingState({
|
||||
|
||||
{headerSeed ? (
|
||||
<>
|
||||
{titleEditor ?? <h2 className="text-xl font-bold leading-tight">
|
||||
<h2 className="text-xl font-bold">
|
||||
{headerSeed.title}
|
||||
</h2>}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full max-w-xl" />
|
||||
<Skeleton className="h-4 w-(--pct-72)" />
|
||||
@@ -1092,29 +1071,17 @@ export function IssueDetailLoadingState({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{taskChatShellEnabled ? (
|
||||
// Chat shell: the thread is the whole surface — alternating bubble
|
||||
// placeholders followed by the docked composer, no tab strip or
|
||||
// properties-card chrome (those don't exist in the chat layout).
|
||||
<div className="flex min-h-0 flex-1 flex-col justify-between gap-6 overflow-hidden">
|
||||
<IssueChatSkeleton />
|
||||
<IssueChatComposerSkeleton />
|
||||
<Skeleton className="h-28 w-full rounded-lg border border-border" />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Skeleton className="h-28 w-full rounded-lg border border-border" />
|
||||
<IssueChatSkeleton />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
<IssueChatSkeleton />
|
||||
</div>
|
||||
|
||||
<IssueSectionSkeleton titleWidth="w-24" rows={3} />
|
||||
</>
|
||||
)}
|
||||
<IssueSectionSkeleton titleWidth="w-24" rows={3} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3528,12 +3495,12 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
}, [issueId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!issue) return;
|
||||
if (!issue || !taskInterfaceSettingsLoaded || !taskRouteReady) return;
|
||||
scheduleIssueDetailPaintMeasure(
|
||||
ISSUE_DETAIL_HEADER_PAINT_MARK,
|
||||
ISSUE_DETAIL_HEADER_MEASURE,
|
||||
);
|
||||
}, [issue?.id]);
|
||||
}, [issue?.id, taskInterfaceSettingsLoaded, taskRouteReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!classicTaskInterfaceEnabled || !issue || commentsLoading) return;
|
||||
@@ -4251,10 +4218,10 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
}),
|
||||
[comments, optimisticComments],
|
||||
);
|
||||
const breadcrumbTitle = issue?.title ?? issueId ?? "Task";
|
||||
const breadcrumbTitle = issue?.title ?? issueHeaderSeed?.title ?? "";
|
||||
const breadcrumbIdentifier =
|
||||
issue?.identifier ?? issueHeaderSeed?.identifier ?? undefined;
|
||||
const breadcrumbStatus = issue?.status;
|
||||
issue?.identifier ?? issueHeaderSeed?.identifier ?? (issueId && /^[a-z0-9]+-\d+$/i.test(issueId) ? issueId : undefined);
|
||||
const breadcrumbStatus = issue?.status ?? issueHeaderSeed?.status;
|
||||
const breadcrumbBlockerAttention = issue?.blockerAttention;
|
||||
// Stable identity for the breadcrumb status glyph. The glyph's shape/colour
|
||||
// depend on status (+ covered state), and its accessible label is derived
|
||||
@@ -4262,7 +4229,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
// not just `state`, to avoid a stale label when counts change.
|
||||
const breadcrumbStatusKey = breadcrumbStatus
|
||||
? `${breadcrumbStatus}|${issue?.externalConversationState ?? ""}|${JSON.stringify(breadcrumbBlockerAttention ?? null)}`
|
||||
: undefined;
|
||||
: "loading-task-status";
|
||||
const breadcrumbStatusLeading = useMemo(
|
||||
() =>
|
||||
breadcrumbStatus ? (
|
||||
@@ -4272,7 +4239,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
className="size-3"
|
||||
blockerAttention={breadcrumbBlockerAttention}
|
||||
/>
|
||||
) : undefined,
|
||||
) : <span aria-hidden="true" className="size-3 shrink-0" />,
|
||||
// `breadcrumbStatusKey` is a complete signature of the inputs below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[breadcrumbStatusKey],
|
||||
@@ -6228,7 +6195,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
externalObjectsState.markdownReferences, uploadAttachment.mutateAsync, feedbackVoteMutation.mutateAsync, agentMap, userProfileMap]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!panelIssue || suppressPanelUntilPlan || (conversation && !conversation.issue) || (taskChatShellEnabled && isMobile)) {
|
||||
if (!taskInterfaceSettingsLoaded || !panelIssue || suppressPanelUntilPlan || (conversation && !conversation.issue) || (taskChatShellEnabled && isMobile)) {
|
||||
closePanel();
|
||||
return;
|
||||
}
|
||||
@@ -6326,6 +6293,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
externalObjectsState.isError,
|
||||
externalObjectsState.refetch,
|
||||
documentDeepLink,
|
||||
taskInterfaceSettingsLoaded,
|
||||
taskChatShellEnabled,
|
||||
taskDocumentEditor,
|
||||
currentUserId,
|
||||
@@ -7369,17 +7337,6 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
if (!taskRouteReady || !taskInterfaceSettingsLoaded)
|
||||
return (
|
||||
<IssueDetailLoadingState
|
||||
titleEditor={taskRouteReady && !issue.conversationAgentId ? (
|
||||
// The account-bound title editor can retain input before the first
|
||||
// session lookup. Presentation-specific controls still wait below.
|
||||
<InlineEditor
|
||||
value={issue.title}
|
||||
onSave={(title) => updateIssue.mutateAsync({ title })}
|
||||
draftKey={`issue:${issue.id}:title`}
|
||||
as="h2"
|
||||
className="text-xl font-semibold leading-normal"
|
||||
/>
|
||||
) : undefined}
|
||||
headerSeed={
|
||||
loadedIssue
|
||||
? readIssueDetailHeaderSeed(
|
||||
|
||||
Reference in new issue
Block a user