mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 12:10:45 +03:00
feat(session-ui): group consecutive user messages under one meta line (#57552)
This commit is contained in:
committed by
GitHub
Unverified
parent
1afe5e2c53
commit
34e3e8fab8
@@ -715,3 +715,42 @@ test("scrolls the timeline behind the composer region, fading at the card, and k
|
||||
await expect.poll(async () => (await dock.boundingBox())!.height).toBeGreaterThan(before + 40)
|
||||
await expect.poll(geometry).toEqual(expected)
|
||||
})
|
||||
|
||||
test("groups consecutive sent prompts under the last one's meta line, with each bubble's actions beside it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const prompt = (id: string, text: string, created: number) =>
|
||||
userMessage([userText(text, { id: `prt_${id}_text` })], { id, created })
|
||||
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
prompt("msg_0001_run", "Group these prompts.", 1700000000000),
|
||||
prompt("msg_0002_run", "Like the iOS app does.", 1700000001000),
|
||||
],
|
||||
})
|
||||
|
||||
const row = (id: string) =>
|
||||
page.locator(`[data-timeline-virtual-content] [data-timeline-row="UserMessage"][data-message-id="${id}"]`)
|
||||
|
||||
const first = row("msg_0001_run")
|
||||
const last = row("msg_0002_run")
|
||||
const bubble = (row: Locator) => row.locator('[data-slot="user-message-text"]')
|
||||
await expect(bubble(last)).toHaveText("Like the iOS app does.")
|
||||
|
||||
// Bubbles of a run sit 4px apart, as in the iOS app, and only the last carries the meta line.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const above = (await bubble(first).boundingBox())!
|
||||
const below = (await bubble(last).boundingBox())!
|
||||
|
||||
return Math.round(below.y - above.y - above.height)
|
||||
})
|
||||
.toBe(4)
|
||||
await expect(first.locator('[data-slot="user-message-meta-tail"]')).toBeHidden()
|
||||
await expect(last.locator('[data-slot="user-message-meta-tail"]')).toBeVisible()
|
||||
|
||||
// The first message keeps its own actions, shown beside its bubble on hover.
|
||||
await bubble(first).hover()
|
||||
await expect(first.locator('[data-slot="user-message-run-actions"]')).toHaveCSS("opacity", "1")
|
||||
await expect(first.getByRole("button", { name: "Copy message" })).toHaveCount(1)
|
||||
})
|
||||
|
||||
@@ -194,6 +194,9 @@
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
/* Where two bubbles of a run meet, their trailing corners tighten to 4px. */
|
||||
border-start-end-radius: calc(10px - 6px * var(--user-run-top, 0));
|
||||
border-end-end-radius: calc(10px - 6px * var(--user-run-bottom, 0));
|
||||
transition:
|
||||
background-color 150ms ease-out,
|
||||
color 150ms ease-out;
|
||||
@@ -230,8 +233,8 @@
|
||||
}
|
||||
|
||||
[data-slot="user-message-copy-wrapper"] {
|
||||
min-height: 24px;
|
||||
margin-top: 4px;
|
||||
height: calc(24px * var(--user-run-meta, 1));
|
||||
margin-top: calc(4px * var(--user-run-meta, 1));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
@@ -292,6 +295,50 @@
|
||||
transition: none;
|
||||
}
|
||||
|
||||
[data-slot="user-message-copy-wrapper"] > * {
|
||||
opacity: var(--user-run-meta, 1);
|
||||
}
|
||||
|
||||
/* The last message of a run carries the meta line; the others fold theirs away and offer their actions beside
|
||||
the bubble. */
|
||||
&[data-run-folded] {
|
||||
[data-slot="user-message-body"] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-slot="user-message-copy-wrapper"] {
|
||||
overflow: clip;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-run-meta-hidden] [data-slot="user-message-copy-wrapper"] {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
[data-slot="user-message-run-actions"] {
|
||||
position: absolute;
|
||||
/* Centered on the bubble's last line. */
|
||||
bottom: calc(8px + (1lh - 24px) / 2);
|
||||
inset-inline-end: calc(100% + 4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
@media (hover: hover) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover [data-slot="user-message-run-actions"],
|
||||
&:focus-within [data-slot="user-message-run-actions"],
|
||||
[data-slot="user-message-run-actions"]:has([data-expanded]) {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.text-text-strong {
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "../tools/tool-renderer"
|
||||
import { currentToolError, currentToolInput, currentToolMetadata, currentToolOutput } from "./current-tool-state"
|
||||
import { currentStreamingInput } from "./streaming-input"
|
||||
import type { UserRun } from "../timeline/user-runs"
|
||||
// Registers the genui_render renderer with the tool registry.
|
||||
import "../genui/tool"
|
||||
|
||||
@@ -30,6 +31,7 @@ export function SessionUserMessage(props: {
|
||||
historicalAgent: string
|
||||
historicalModel: SessionMessageAssistant["model"]
|
||||
actions?: SessionUserActions
|
||||
run?: UserRun
|
||||
}) {
|
||||
return (
|
||||
<CurrentUserMessageDisplay
|
||||
@@ -42,6 +44,7 @@ export function SessionUserMessage(props: {
|
||||
agent={props.historicalAgent}
|
||||
model={props.historicalModel}
|
||||
actions={props.actions}
|
||||
run={props.run}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Index,
|
||||
onCleanup,
|
||||
Show,
|
||||
untrack,
|
||||
type ComponentProps,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
@@ -33,6 +34,8 @@ import { TextShimmer } from "@opencode/ui/text-shimmer"
|
||||
import { BasicTool } from "../components/basic-tool"
|
||||
import { reasoningHeading } from "../timeline/projection"
|
||||
import { Card } from "@opencode/ui/card"
|
||||
import { springStyle } from "@opencode/ui/motion/solid"
|
||||
import { userRunSpring, type UserRun } from "../timeline/user-runs"
|
||||
import type {
|
||||
PromptAgentAttachment,
|
||||
PromptFileAttachment,
|
||||
@@ -267,6 +270,7 @@ export function CurrentUserMessageDisplay(props: {
|
||||
comments?: SessionUserComment[]
|
||||
references?: SessionUserAttachmentReference[]
|
||||
voice?: boolean
|
||||
run?: UserRun
|
||||
}) {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
@@ -299,6 +303,31 @@ export function CurrentUserMessageDisplay(props: {
|
||||
|
||||
const stamp = createMemo(() => timefmt().format(props.message.time.created))
|
||||
|
||||
// In a run, only the last message keeps the meta line; the others offer their actions beside the bubble.
|
||||
// Bubble corners tighten where two bubbles meet, as in the iOS app's grouped corners.
|
||||
const folded = createMemo(() => !!props.run?.below && !!props.text)
|
||||
|
||||
const meetsAbove = createMemo(
|
||||
() => !!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 })
|
||||
// The first paint is the resting pose; the spring's first write lands a frame later.
|
||||
const restingPose = untrack(runPose)
|
||||
|
||||
let root: HTMLDivElement | undefined
|
||||
springStyle(
|
||||
() => root,
|
||||
runPose,
|
||||
(el, value) => {
|
||||
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.toggleAttribute("data-run-meta-hidden", value.meta === 0)
|
||||
},
|
||||
userRunSpring,
|
||||
)
|
||||
|
||||
const copy = async (text: string) => {
|
||||
if (!text || !(await writeClipboard(text))) return
|
||||
setState("copied", true)
|
||||
@@ -325,6 +354,96 @@ export function CurrentUserMessageDisplay(props: {
|
||||
setState("updating", false)
|
||||
}
|
||||
|
||||
const renderActions = () => (
|
||||
<>
|
||||
<Show when={pending()}>
|
||||
<MessageActionButton
|
||||
icon="arrow-down-to-line"
|
||||
label={i18n.t("ui.message.moveToQueue")}
|
||||
disabled={state.updating}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void updatePending("queue")
|
||||
}}
|
||||
aria-label={i18n.t("ui.message.moveToQueue")}
|
||||
/>
|
||||
<MessageActionButton
|
||||
icon="outline-trash"
|
||||
label={i18n.t("ui.message.deletePending")}
|
||||
disabled={state.updating}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void updatePending("remove")
|
||||
}}
|
||||
aria-label={i18n.t("ui.message.deletePending")}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={props.actions?.revert && !pending()}>
|
||||
<MessageActionButton
|
||||
icon="reset"
|
||||
label={i18n.t("ui.message.revertMessage")}
|
||||
disabled={state.reverting}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void revert()
|
||||
}}
|
||||
aria-label={i18n.t("ui.message.revertMessage")}
|
||||
/>
|
||||
</Show>
|
||||
{/* Like the iOS app, a Voice message's copy also offers the full prompt the session received, on right click. */}
|
||||
<Show
|
||||
when={props.voice}
|
||||
fallback={
|
||||
<Show when={props.text}>
|
||||
<MessageActionButton
|
||||
icon={state.copied ? "check" : "copy"}
|
||||
label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void copy(props.text)
|
||||
}}
|
||||
aria-label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Menu.Context modal={false}>
|
||||
<Tooltip
|
||||
appearance="compact"
|
||||
value={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
|
||||
placement="top"
|
||||
gutter={4}
|
||||
>
|
||||
<Menu.Context.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name={state.copied ? "check" : "outline-copy"} size="small" />}
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void copy(props.text)
|
||||
}}
|
||||
aria-label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Menu.Context.Portal>
|
||||
<Menu.Context.Content>
|
||||
<Menu.Item onSelect={() => void copy(props.text)}>{i18n.t("ui.message.copyMessage")}</Menu.Item>
|
||||
<Menu.Item onSelect={() => void copy(props.message.text)}>
|
||||
{i18n.t("ui.message.copyFullMessage")}
|
||||
</Menu.Item>
|
||||
</Menu.Context.Content>
|
||||
</Menu.Context.Portal>
|
||||
</Menu.Context>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
|
||||
const renderAttachments = () => (
|
||||
<Show when={attachments().length > 0 || references().length > 0}>
|
||||
<div data-slot="user-message-attachments">
|
||||
@@ -381,8 +500,16 @@ export function CurrentUserMessageDisplay(props: {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={root}
|
||||
data-component="user-message"
|
||||
data-pending={waiting() ? "true" : undefined}
|
||||
data-run-folded={folded() ? "" : undefined}
|
||||
data-run-meta-hidden={restingPose.meta === 0 ? "" : undefined}
|
||||
style={{
|
||||
"--user-run-meta": `${restingPose.meta}`,
|
||||
"--user-run-top": `${restingPose.top}`,
|
||||
"--user-run-bottom": `${restingPose.bottom}`,
|
||||
}}
|
||||
data-timeline-part-id={props.text ? `${props.message.id}:text:0` : undefined}
|
||||
>
|
||||
<Show when={waiting()}>
|
||||
@@ -415,6 +542,9 @@ export function CurrentUserMessageDisplay(props: {
|
||||
<UserMessageComments comments={comments()} bounded />
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={folded()}>
|
||||
<div data-slot="user-message-run-actions">{renderActions()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
{/* Like the TUI, a pending steer of only attachments still offers its actions. */}
|
||||
@@ -435,91 +565,7 @@ export function CurrentUserMessageDisplay(props: {
|
||||
{stamp()}
|
||||
</span>
|
||||
</span>
|
||||
<Show when={pending()}>
|
||||
<MessageActionButton
|
||||
icon="arrow-down-to-line"
|
||||
label={i18n.t("ui.message.moveToQueue")}
|
||||
disabled={state.updating}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void updatePending("queue")
|
||||
}}
|
||||
aria-label={i18n.t("ui.message.moveToQueue")}
|
||||
/>
|
||||
<MessageActionButton
|
||||
icon="outline-trash"
|
||||
label={i18n.t("ui.message.deletePending")}
|
||||
disabled={state.updating}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void updatePending("remove")
|
||||
}}
|
||||
aria-label={i18n.t("ui.message.deletePending")}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={props.actions?.revert && !pending()}>
|
||||
<MessageActionButton
|
||||
icon="reset"
|
||||
label={i18n.t("ui.message.revertMessage")}
|
||||
disabled={state.reverting}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void revert()
|
||||
}}
|
||||
aria-label={i18n.t("ui.message.revertMessage")}
|
||||
/>
|
||||
</Show>
|
||||
{/* Like the iOS app, a Voice message's copy also offers the full prompt the session received, on right click. */}
|
||||
<Show
|
||||
when={props.voice}
|
||||
fallback={
|
||||
<Show when={props.text}>
|
||||
<MessageActionButton
|
||||
icon={state.copied ? "check" : "copy"}
|
||||
label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void copy(props.text)
|
||||
}}
|
||||
aria-label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Menu.Context modal={false}>
|
||||
<Tooltip
|
||||
appearance="compact"
|
||||
value={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
|
||||
placement="top"
|
||||
gutter={4}
|
||||
>
|
||||
<Menu.Context.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name={state.copied ? "check" : "outline-copy"} size="small" />}
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void copy(props.text)
|
||||
}}
|
||||
aria-label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Menu.Context.Portal>
|
||||
<Menu.Context.Content>
|
||||
<Menu.Item onSelect={() => void copy(props.text)}>{i18n.t("ui.message.copyMessage")}</Menu.Item>
|
||||
<Menu.Item onSelect={() => void copy(props.message.text)}>
|
||||
{i18n.t("ui.message.copyFullMessage")}
|
||||
</Menu.Item>
|
||||
</Menu.Context.Content>
|
||||
</Menu.Context.Portal>
|
||||
</Menu.Context>
|
||||
</Show>
|
||||
{renderActions()}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -6,9 +6,10 @@ import type {
|
||||
} from "@opencode/client/promise"
|
||||
import { useI18n } from "@opencode/ui/context/i18n"
|
||||
import { Option, Predicate, Schema } from "effect"
|
||||
import { For, Show, createEffect, createMemo, type Accessor, type JSX } from "solid-js"
|
||||
import { For, Show, createEffect, createMemo, untrack, type Accessor, type JSX } from "solid-js"
|
||||
import { DiffChanges } from "@opencode/ui/diff-changes"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { springStyle } from "@opencode/ui/motion/solid"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { SessionUserActions, SessionUserAttachmentReference, SessionUserComment } from "../actions"
|
||||
import { useData } from "../context"
|
||||
@@ -33,6 +34,8 @@ import { SessionRetry } from "../components/session-retry"
|
||||
import { SessionError } from "../components/session-error"
|
||||
import { timelineCategory, type TimelineDetail } from "./detail"
|
||||
import { currentToolFailed } from "../message/current-tool-state"
|
||||
import { userMessageRuns, userRunSpring } from "./user-runs"
|
||||
import { attached } from "../components/message-file"
|
||||
import {
|
||||
createReactiveTimelineProjection,
|
||||
Timeline,
|
||||
@@ -114,6 +117,33 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
return owners
|
||||
})
|
||||
|
||||
const runs = createMemo(() => {
|
||||
const messages = input.projection.messageByID()
|
||||
const context = input.projection.userContextByID()
|
||||
|
||||
return userMessageRuns(input.projection.rows(), [...messages.values()], (id) => {
|
||||
const message = messages.get(id)
|
||||
const user = context.get(id)
|
||||
|
||||
if (message?.type !== "user" || !user) return undefined
|
||||
const status = input.actions?.pending?.status(id)
|
||||
const presentation = input.presentation(message)
|
||||
|
||||
return {
|
||||
kind: status === "steering" || status === "stranded" ? "pending" : "sent",
|
||||
agent: user.agent,
|
||||
model: user.model,
|
||||
text: !!(presentation?.displayText ?? message.text),
|
||||
attachments: (message.files ?? []).some(attached) || !!presentation?.references?.length,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const run = (messageID: string) =>
|
||||
createMemo(() => runs().get(messageID), undefined, {
|
||||
equals: (a, b) => a?.above === b?.above && a?.below === b?.below,
|
||||
})
|
||||
|
||||
const workingTurn = (messageID: string) =>
|
||||
input.status().type !== "idle" && input.projection.activeMessageID() === messageID
|
||||
|
||||
@@ -255,7 +285,9 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
}
|
||||
reasoningOpen={(id) => input.disclosure.value(id)}
|
||||
onReasoningOpenChange={(id, open) => input.disclosure.set(id, open)}
|
||||
toolDefaultOpen={(tool) => (input.timelineDetail || tool.name === "execute" ? contentDefaultOpen(tool) : false)}
|
||||
toolDefaultOpen={(tool) =>
|
||||
input.timelineDetail || tool.name === "execute" ? contentDefaultOpen(tool) : false
|
||||
}
|
||||
toolOpen={(id) => input.disclosure.value(`${row().group.key}:tool:${id}`)}
|
||||
onToolOpenChange={(id, open) => input.disclosure.set(`${row().group.key}:tool:${id}`, open)}
|
||||
fileOpen={(path) =>
|
||||
@@ -787,8 +819,31 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
)
|
||||
}
|
||||
|
||||
// A run's messages sit as close as the iOS app's (4 px); the gap springs as a message joins or leaves.
|
||||
function TurnGap(props: { messageID: string }) {
|
||||
let element: HTMLDivElement | undefined
|
||||
const joined = run(props.messageID)
|
||||
springStyle(
|
||||
() => element,
|
||||
() => ({ height: joined()?.above ? 4 : 24 }),
|
||||
(el, value) => {
|
||||
el.style.height = `${value.height}px`
|
||||
},
|
||||
userRunSpring,
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={element}
|
||||
data-timeline-row="TurnGap"
|
||||
aria-hidden="true"
|
||||
style={{ height: untrack(joined)?.above ? "4px" : "24px" }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const render = (row: Accessor<TimelineRow.TimelineRow>, onSizeChange?: () => void) => {
|
||||
if (Predicate.isTagged(row(), "TurnGap")) return <div data-timeline-row="TurnGap" aria-hidden="true" class="h-6" />
|
||||
if (Predicate.isTagged(row(), "TurnGap")) return <TurnGap messageID={row().userMessageID} />
|
||||
|
||||
// The host owns live status and renders this slot itself.
|
||||
if (Predicate.isTagged(row(), "Activity")) return null
|
||||
@@ -809,6 +864,7 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
})
|
||||
|
||||
const context = createMemo(() => input.projection.userContextByID().get(current().userMessageID))
|
||||
const joined = run(current().userMessageID)
|
||||
|
||||
return (
|
||||
<Frame row={current()}>
|
||||
@@ -829,6 +885,7 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
historicalAgent={context()?.agent ?? ""}
|
||||
historicalModel={context()?.model ?? { id: "", providerID: "" }}
|
||||
actions={input.actions}
|
||||
run={joined()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { timelinePresets } from "./detail"
|
||||
import { Timeline } from "./projection"
|
||||
import { userMessageRuns, type UserRunMember } from "./user-runs"
|
||||
|
||||
const user = (id: string, created: number) =>
|
||||
({ id, type: "user", text: id, time: { created } }) satisfies SessionMessageInfo
|
||||
|
||||
const assistant = (id: string, parentID: string, created: number) =>
|
||||
({
|
||||
id,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
metadata: { parentID },
|
||||
content: [{ type: "text", text: "answer" }],
|
||||
time: { created, completed: created + 1 },
|
||||
}) satisfies SessionMessageInfo
|
||||
|
||||
const sent: UserRunMember = {
|
||||
kind: "sent",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
text: true,
|
||||
attachments: false,
|
||||
}
|
||||
|
||||
function runs(
|
||||
messages: SessionMessageInfo[],
|
||||
members: Record<string, Partial<UserRunMember>> = {},
|
||||
preset?: (typeof timelinePresets)[number]["id"],
|
||||
) {
|
||||
const pending = new Set(Object.keys(members).filter((id) => members[id]?.kind === "pending"))
|
||||
const detail = timelinePresets.find((item) => item.id === preset)?.value
|
||||
|
||||
const rows = Timeline.constructSessionMessageRows(
|
||||
messages,
|
||||
true,
|
||||
{ type: "busy" },
|
||||
pending,
|
||||
false,
|
||||
false,
|
||||
undefined,
|
||||
detail,
|
||||
).rows
|
||||
|
||||
return Object.fromEntries(userMessageRuns(rows, messages, (id) => ({ ...sent, ...members[id] })))
|
||||
}
|
||||
|
||||
const alone = { above: false, below: false }
|
||||
|
||||
describe("userMessageRuns", () => {
|
||||
test("joins consecutive sent messages into one run", () => {
|
||||
expect(runs([user("msg_1", 1), user("msg_2", 2), user("msg_3", 3)])).toEqual({
|
||||
msg_1: { above: false, below: true },
|
||||
msg_2: { above: true, below: true },
|
||||
msg_3: { above: true, below: false },
|
||||
})
|
||||
})
|
||||
|
||||
test("assistant output between two messages ends the run", () => {
|
||||
expect(runs([user("msg_1", 1), assistant("msg_2", "msg_1", 2), user("msg_3", 3), user("msg_4", 4)])).toEqual({
|
||||
msg_1: alone,
|
||||
msg_3: { above: false, below: true },
|
||||
msg_4: { above: true, below: false },
|
||||
})
|
||||
})
|
||||
|
||||
test("pending input never joins a sent message or another pending one", () => {
|
||||
expect(
|
||||
runs([user("msg_1", 1), user("msg_2", 2), user("msg_3", 3), user("msg_4", 4)], {
|
||||
msg_3: { kind: "pending" },
|
||||
msg_4: { kind: "pending" },
|
||||
}),
|
||||
).toEqual({
|
||||
msg_1: { above: false, below: true },
|
||||
msg_2: { above: true, below: false },
|
||||
msg_3: alone,
|
||||
msg_4: alone,
|
||||
})
|
||||
})
|
||||
|
||||
test("a different agent or model ends the run, since the run names one", () => {
|
||||
expect(
|
||||
runs([user("msg_1", 1), user("msg_2", 2), user("msg_3", 3)], {
|
||||
msg_2: { agent: "plan" },
|
||||
msg_3: { agent: "plan", model: { id: "model", providerID: "provider", variant: "high" } },
|
||||
}),
|
||||
).toEqual({ msg_1: alone, msg_2: alone, msg_3: alone })
|
||||
})
|
||||
|
||||
test("a switch or compaction ends the run even when the timeline hides its notice", () => {
|
||||
const messages = [
|
||||
user("msg_1", 1),
|
||||
{ id: "msg_2", type: "model-switched", model: { id: "model", providerID: "provider" }, time: { created: 2 } },
|
||||
user("msg_3", 3),
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
expect(runs(messages, {}, "quiet")).toEqual({ msg_1: alone, msg_3: alone })
|
||||
})
|
||||
|
||||
test("only text bubbles join; attachments keep their own gap and the text above keeps its meta line", () => {
|
||||
expect(
|
||||
runs([user("msg_1", 1), user("msg_2", 2), user("msg_3", 3), user("msg_4", 4)], {
|
||||
msg_2: { text: false, attachments: true },
|
||||
msg_4: { attachments: true },
|
||||
}),
|
||||
).toEqual({ msg_1: alone, msg_2: alone, msg_3: alone, msg_4: alone })
|
||||
})
|
||||
|
||||
test("a lone message stands alone", () => {
|
||||
expect(runs([user("msg_1", 1), assistant("msg_2", "msg_1", 2)])).toEqual({ msg_1: alone })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { Predicate } from "effect"
|
||||
import type { TimelineRow } from "./timeline-row"
|
||||
|
||||
/** A user message's place in a run of consecutive user messages: joined to the one above, below, or both. */
|
||||
export type UserRun = { above: boolean; below: boolean }
|
||||
|
||||
/** What a user message brings to a run: its delivery, the agent and model its meta line names, and its surfaces. */
|
||||
export type UserRunMember = {
|
||||
kind: "sent" | "pending"
|
||||
agent: string
|
||||
model: SessionMessageAssistant["model"]
|
||||
/** It has a text bubble, the surface that carries a run's meta line. */
|
||||
text: boolean
|
||||
/** Attachments sit above its bubble. */
|
||||
attachments: boolean
|
||||
}
|
||||
|
||||
/** Layout moves in a run settle on Kit's calm spring: .4 s, bounce 0. */
|
||||
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.
|
||||
*/
|
||||
export function userMessageRuns(
|
||||
rows: readonly TimelineRow.TimelineRow[],
|
||||
messages: readonly SessionMessageInfo[],
|
||||
member: (messageID: string) => UserRunMember | undefined,
|
||||
) {
|
||||
const segments = boundarySegments(messages)
|
||||
const members = rows.filter((row) => !Predicate.isTagged(row, "TurnGap"))
|
||||
|
||||
const joins = (above: TimelineRow.TimelineRow | undefined, below: TimelineRow.TimelineRow | undefined) => {
|
||||
if (!Predicate.isTagged(above, "UserMessage") || !Predicate.isTagged(below, "UserMessage")) return false
|
||||
const upper = member(above.userMessageID)
|
||||
const lower = member(below.userMessageID)
|
||||
|
||||
return (
|
||||
!!upper &&
|
||||
!!lower &&
|
||||
upper.kind === "sent" &&
|
||||
lower.kind === "sent" &&
|
||||
upper.text &&
|
||||
lower.text &&
|
||||
!lower.attachments &&
|
||||
upper.agent === lower.agent &&
|
||||
upper.model.providerID === lower.model.providerID &&
|
||||
upper.model.id === lower.model.id &&
|
||||
upper.model.variant === lower.model.variant &&
|
||||
segments.get(above.userMessageID) === segments.get(below.userMessageID)
|
||||
)
|
||||
}
|
||||
|
||||
return new Map(
|
||||
members.flatMap((row, index) =>
|
||||
Predicate.isTagged(row, "UserMessage")
|
||||
? [
|
||||
[
|
||||
row.userMessageID,
|
||||
{ above: joins(members[index - 1], row), below: joins(row, members[index + 1]) } satisfies UserRun,
|
||||
] as const,
|
||||
]
|
||||
: [],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Each user message's count of agent, model, location and compaction boundaries before it. */
|
||||
function boundarySegments(messages: readonly SessionMessageInfo[]) {
|
||||
const segments = new Map<string, number>()
|
||||
let segment = 0
|
||||
messages.forEach((message) => {
|
||||
if (
|
||||
message.type === "agent-switched" ||
|
||||
message.type === "model-switched" ||
|
||||
message.type === "location-switched" ||
|
||||
message.type === "compaction"
|
||||
)
|
||||
segment += 1
|
||||
|
||||
if (message.type === "user") segments.set(message.id, segment)
|
||||
})
|
||||
|
||||
return segments
|
||||
}
|
||||
Reference in New Issue
Block a user