diff --git a/packages/app/e2e/regression/session-queue.spec.ts b/packages/app/e2e/regression/session-queue.spec.ts index 78468abe29a..c175d9e470c 100644 --- a/packages/app/e2e/regression/session-queue.spec.ts +++ b/packages/app/e2e/regression/session-queue.spec.ts @@ -921,6 +921,31 @@ for (const [action, status] of [ }) } +test("waiting steers group under one Pending line, each keeping its own actions beside its bubble", async ({ + page, +}) => { + const mock = createQueueMock( + ["U2: Also check the retry path.", "U3: And rebase on beta first."], + [{ id: "msg_queue_delivered", type: "user", text: "First prompt", time: { created: 1 } }], + ) + + mock.rows.forEach((row) => (row.delivery = "steer")) + await openQueue(page, mock) + const first = userRow(page, mock.rows[0].id) + const last = userRow(page, mock.rows[1].id) + await expect(last).toContainText("U3: And rebase on beta first.") + await expect(first.locator('[data-component="user-message"]')).toHaveAttribute("data-pending", "true") + + // The run's last steer carries the one Pending line; the first folds its line away. + await expect(last.locator('[data-slot="user-message-meta"]')).toContainText("Pending") + await expect(first.locator('[data-slot="user-message-meta"]')).toBeHidden() + + await first.locator('[data-slot="user-message-text"]').hover() + await expect(first.locator('[data-slot="user-message-run-actions"]')).toHaveCSS("opacity", "1") + await first.getByRole("button", { name: "Move to queue" }).click() + await expect.poll(() => mock.changes).toEqual([{ inboxID: mock.rows[0].id, action: "queue" }]) +}) + test("/undo returns a pending steer's review comment to the composer", async ({ page }) => { const display = "tighten this" const comment = "check the guard" diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index 12d846554e9..d1b3b17ffd5 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -174,13 +174,16 @@ background: var(--v2-background-bg-layer-02); } - &[data-pending] { + /* A run of waiting steers wears one badge, on its first bubble. It fades as the steer joins a run or delivers, + and its room springs with it. */ + &:has([data-slot="user-message-pending"]) { position: relative; - margin-block-start: 8px; + margin-block-start: calc(8px * var(--user-run-badge, 0)); [data-slot="user-message-pending"] { inset-inline-start: auto; inset-inline-end: -8px; + opacity: var(--user-run-badge, 0); } } diff --git a/packages/session-ui/src/message/message-content.tsx b/packages/session-ui/src/message/message-content.tsx index a7ec02eea8f..96ecf09d079 100644 --- a/packages/session-ui/src/message/message-content.tsx +++ b/packages/session-ui/src/message/message-content.tsx @@ -311,7 +311,18 @@ export function CurrentUserMessageDisplay(props: { () => !!props.run?.above && !!props.text && attachments().length === 0 && references().length === 0, ) - const runPose = () => ({ meta: folded() ? 0 : 1, top: meetsAbove() ? 1 : 0, bottom: folded() ? 1 : 0 }) + // A run of waiting steers wears one badge, on its first steer. + const badge = createMemo(() => waiting() && !props.run?.above) + // Once shown, the badge stays mounted so it can fade out on delivery. + const badged = createMemo((shown: boolean) => shown || badge(), false) + + const runPose = () => ({ + meta: folded() ? 0 : 1, + top: meetsAbove() ? 1 : 0, + bottom: folded() ? 1 : 0, + badge: badge() ? 1 : 0, + }) + // The first paint is the resting pose; the spring's first write lands a frame later. const restingPose = untrack(runPose) @@ -323,6 +334,7 @@ export function CurrentUserMessageDisplay(props: { el.style.setProperty("--user-run-meta", `${value.meta}`) el.style.setProperty("--user-run-top", `${value.top}`) el.style.setProperty("--user-run-bottom", `${value.bottom}`) + el.style.setProperty("--user-run-badge", `${value.badge}`) el.toggleAttribute("data-run-meta-hidden", value.meta === 0) }, userRunSpring, @@ -509,11 +521,18 @@ export function CurrentUserMessageDisplay(props: { "--user-run-meta": `${restingPose.meta}`, "--user-run-top": `${restingPose.top}`, "--user-run-bottom": `${restingPose.bottom}`, + "--user-run-badge": `${restingPose.badge}`, }} data-timeline-part-id={props.text ? `${props.message.id}:text:0` : undefined} > - -
+ +
diff --git a/packages/session-ui/src/timeline/user-runs.test.ts b/packages/session-ui/src/timeline/user-runs.test.ts index 787a8f07cef..7b3ee2efc7e 100644 --- a/packages/session-ui/src/timeline/user-runs.test.ts +++ b/packages/session-ui/src/timeline/user-runs.test.ts @@ -67,7 +67,7 @@ describe("userMessageRuns", () => { }) }) - test("pending input never joins a sent message or another pending one", () => { + test("waiting steers group together but never with a sent message", () => { expect( runs([user("msg_1", 1), user("msg_2", 2), user("msg_3", 3), user("msg_4", 4)], { msg_3: { kind: "pending" }, @@ -76,8 +76,31 @@ describe("userMessageRuns", () => { ).toEqual({ msg_1: { above: false, below: true }, msg_2: { above: true, below: false }, + msg_3: { above: false, below: true }, + msg_4: { above: true, below: false }, + }) + }) + + test("a run of steers stays one run as it delivers", () => { + const steers = [user("msg_1", 1), assistant("msg_2", "msg_1", 2), user("msg_3", 3), user("msg_4", 4)] + + const delivered = { msg_1: alone, msg_3: { above: false, below: true }, msg_4: { above: true, below: false } } + + expect(runs(steers, { msg_3: { kind: "pending" }, msg_4: { kind: "pending" } })).toEqual(delivered) + expect(runs(steers)).toEqual(delivered) + }) + + test("a steer delivered ahead of the rest leaves the run", () => { + expect( + runs([user("msg_1", 1), assistant("msg_2", "msg_1", 2), user("msg_3", 3), user("msg_4", 4), user("msg_5", 5)], { + msg_4: { kind: "pending" }, + msg_5: { kind: "pending" }, + }), + ).toEqual({ + msg_1: alone, msg_3: alone, - msg_4: alone, + msg_4: { above: false, below: true }, + msg_5: { above: true, below: false }, }) }) diff --git a/packages/session-ui/src/timeline/user-runs.ts b/packages/session-ui/src/timeline/user-runs.ts index 8e1cde90cb9..a7608ba8026 100644 --- a/packages/session-ui/src/timeline/user-runs.ts +++ b/packages/session-ui/src/timeline/user-runs.ts @@ -21,8 +21,9 @@ export const userRunSpring = { omega: (2 * Math.PI) / 0.4, zeta: 1 } /** * Like the iOS app's grouped corners, consecutive user messages with nothing but turn gaps between them read as - * one block under a single meta line. Only sent text bubbles with the same agent and model join, and an agent, - * model, location or compaction boundary between them ends the run even when the timeline hides its notice. + * one block under a single meta line. Text bubbles with the same agent and model join, and steers waiting for + * delivery group the same way, but never with a sent message. An agent, model, location or compaction boundary + * between them ends the run even when the timeline hides its notice. */ export function userMessageRuns( rows: readonly TimelineRow.TimelineRow[], @@ -40,8 +41,7 @@ export function userMessageRuns( return ( !!upper && !!lower && - upper.kind === "sent" && - lower.kind === "sent" && + upper.kind === lower.kind && upper.text && lower.text && !lower.attachments &&