mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
feat(session-ui): group queued and steered messages (#57561)
This commit is contained in:
committed by
GitHub
Unverified
parent
409d6bb710
commit
4f8d9ccb49
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<Show when={waiting()}>
|
||||
<div data-slot="user-message-pending" role="img" aria-label={i18n.t("ui.message.pending")}>
|
||||
<Show when={badged()}>
|
||||
<div
|
||||
data-slot="user-message-pending"
|
||||
role="img"
|
||||
aria-label={i18n.t("ui.message.pending")}
|
||||
aria-hidden={badge() ? undefined : "true"}
|
||||
style={{ "pointer-events": badge() ? undefined : "none" }}
|
||||
>
|
||||
<Tooltip appearance="compact" value={i18n.t("ui.message.pending")} placement="top" gutter={4}>
|
||||
<Icon name="outline-steer" size="small" />
|
||||
</Tooltip>
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user