fix(app): remove the message menu experiment (#58867)

This commit is contained in:
Kit Langton
2026-10-25 08:24:33 -07:00
committed by GitHub
Unverified
parent 80dc5235f5
commit fc7af90caf
8 changed files with 12 additions and 173 deletions
@@ -1,16 +0,0 @@
import { motionTuning } from "@opencode/ui/motion-tuning"
import { createSignal } from "solid-js"
const toggle = motionTuning.toggle("session.messageMenu", {
label: "Message menu",
group: "Composer",
experiment: true,
value: false,
})
const [enabled, setEnabled] = createSignal(toggle())
motionTuning.subscribe(() => setEnabled(toggle()))
/** Experiment: a ⋯ menu per message in a 28px trailing gutter replaces the hover info line, and turns sit 24px apart. */
export const messageMenu = enabled
@@ -36,7 +36,6 @@ import {
import { SessionHeaderSpacer } from "@/session/header/session-header"
import { SessionHeaderGap } from "@/session/header/drag-area"
import { SessionWorkingIndicator } from "@/session/header/session-working-indicator"
import { messageMenu } from "@/session/timeline/message-menu"
type BlockingTask = { type: "shell" | "subagent"; partID: string; id?: string; label?: string }
@@ -172,8 +171,7 @@ function MessageTimelineView(
if (!directory) return
void data.location.vcs.sync({ directory }).catch(() => undefined)
})
// The message menu experiment reserves a 28px trailing gutter (24px button + 4px gap) for its ⋯ button.
const turnPadding = () => (messageMenu() ? "ps-4 pe-11 md:ps-6 md:pe-13" : "px-4 md:px-6")
const turnPadding = () => "px-4 md:px-6"
const showHeader = createMemo(() => !props.hideHeader && (props.data.showHeader() || workspaceSession()))
const pinned = createMemo(() => props.pinned)
const messageByID = projection.messageByID
@@ -1,7 +1,5 @@
import { DataProvider } from "@opencode/session-ui/context"
import { MarkdownProvider, type ReadMarkdownImage } from "@opencode/session-ui/context/markdown"
import { MessageMenuProvider } from "@opencode/session-ui/context/message-menu"
import { messageMenu } from "@/session/timeline/message-menu"
import { useNavigate, useParams } from "@solidjs/router"
import { createMemo, type ParentProps } from "solid-js"
import { useProviders } from "@/providers/catalog/providers"
@@ -142,7 +140,7 @@ export function SessionUIProvider(
}
>
<MarkdownProvider readImage={readImage()} openSession={openReferencedSession} sessionTitle={sessionTitle}>
<MessageMenuProvider enabled={messageMenu()}>{props.children}</MessageMenuProvider>
{props.children}
</MarkdownProvider>
</DataProvider>
</LocalProvider>
@@ -1740,53 +1740,3 @@
color: var(--v2-text-text-muted);
transition: none;
}
/* The message options button sits in the 28px gutter the timeline reserves on the trailing side. */
[data-component="user-message"],
[data-component="text-part"] {
position: relative;
[data-slot="message-options"] {
position: absolute;
top: calc(0.5lh - 12px);
inset-inline-end: -28px;
z-index: 1;
display: flex;
@media (hover: hover) {
opacity: 0;
}
}
&:hover [data-slot="message-options"],
&:focus-within [data-slot="message-options"],
[data-slot="message-options"]:has([data-expanded]) {
opacity: 1;
}
}
/* Centered on the bubble's first line. */
[data-component="user-message"] [data-slot="message-options"] {
top: calc(8px + 0.5lh - 12px);
}
[data-slot="message-options-info"] {
display: flex;
flex-direction: column;
gap: 2px;
max-width: 340px;
padding: 4px 8px;
font-family: var(--v2-font-family-sans);
font-size: 12px;
font-weight: 440;
line-height: var(--line-height-compact);
color: var(--v2-text-text-faint);
user-select: text;
cursor: default;
> span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@@ -1,12 +0,0 @@
import { createContext, useContext, type ParentProps } from "solid-js"
const context = createContext<() => boolean>(() => false)
/** Experiment: a ⋯ menu per message in a trailing gutter replaces the hover info line, and turns sit further apart. */
export function MessageMenuProvider(props: ParentProps<{ enabled: boolean }>) {
return <context.Provider value={() => props.enabled}>{props.children}</context.Provider>
}
export function useMessageMenu() {
return useContext(context)
}
@@ -21,7 +21,6 @@ import { Markdown } from "../components/markdown"
import { linkSegments } from "../components/link-segments"
import { sessionReferenceText, setupSessionLinks } from "../components/markdown-session-links"
import { useMarkdown } from "../context/markdown"
import { useMessageMenu } from "../context/message-menu"
import { useSources } from "../context/sources"
import { SourcesChip } from "../sources/sources-chip"
import type { TurnSources } from "../sources/sources"
@@ -100,12 +99,6 @@ function modelItems(data: ReturnType<typeof useData>, model: SessionMessageAssis
return [{ label: name }, { label: model.variant[0]?.toUpperCase() + model.variant.slice(1), variant: true }]
}
function metaText(items: MetaItem[]) {
return items
.map((item, index) => (index === 0 ? item.label : item.variant ? ` ${item.label}` : ` \u00B7 ${item.label}`))
.join("")
}
function MetaItems(props: { items: MetaItem[]; slot: "user-message" | "text-part" }) {
return (
<For each={props.items}>
@@ -153,35 +146,6 @@ function MessageActionButton(
)
}
function MessageOptions(props: { info: string[]; children: JSX.Element }) {
const i18n = useI18n()
return (
<div data-slot="message-options">
<Menu gutter={4} modal={false} placement="bottom-end">
<Menu.Trigger
as={IconButton}
icon={<Icon name="outline-dots" size="small" />}
size="normal"
variant="ghost-muted"
aria-label={i18n.t("ui.message.options")}
/>
<Menu.Portal>
<Menu.Content>
<Show when={props.info.length > 0}>
<div data-slot="message-options-info">
<For each={props.info}>{(line) => <span dir="auto">{line}</span>}</For>
</div>
<Menu.Separator />
</Show>
{props.children}
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
)
}
function UserMessageTransition(props: { active: boolean; children: JSX.Element }) {
return (
<span
@@ -356,7 +320,6 @@ export function CurrentUserMessageDisplay(props: {
const data = useData()
const dialog = useDialog()
const i18n = useI18n()
const menu = useMessageMenu()
const [state, setState] = createStore({ copied: false, reverting: false, forking: false, updating: false })
@@ -395,13 +358,6 @@ export function CurrentUserMessageDisplay(props: {
return i18n.t(RELATIVE_TIME_KEYS[relative.unit], { count: numfmt().format(relative.count) })
})
const info = createMemo(() =>
[
waiting() ? i18n.t("ui.message.pending") : "",
metaText(labels()),
fulltimefmt().format(props.message.time.created),
].filter(Boolean),
)
// 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.
@@ -667,29 +623,6 @@ export function CurrentUserMessageDisplay(props: {
}}
data-timeline-part-id={props.text ? `${props.message.id}:text:0` : undefined}
>
<Show when={menu() && (props.text || comments().length > 0 || pending())}>
<MessageOptions info={info()}>
<Show when={props.actions?.pending && pending()}>
<Menu.Item disabled={state.updating} onSelect={() => void updatePending("queue")}>
{i18n.t("ui.message.moveToQueue")}
</Menu.Item>
<Menu.Item disabled={state.updating} onSelect={() => void updatePending("remove")}>
{i18n.t("ui.message.deletePending")}
</Menu.Item>
</Show>
<Show when={props.actions?.revert && !pending()}>
<Menu.Item disabled={state.reverting} onSelect={() => void revert()}>
{i18n.t("ui.message.revertMessage")}
</Menu.Item>
</Show>
<Show when={props.text}>
<Menu.Item onSelect={() => void copy(props.text)}>{i18n.t("ui.message.copyMessage")}</Menu.Item>
</Show>
<Show when={props.voice}>
<Menu.Item onSelect={() => void copy(props.message.text)}>{i18n.t("ui.message.copyFullMessage")}</Menu.Item>
</Show>
</MessageOptions>
</Show>
{renderAttachments()}
<Show
when={props.text}
@@ -713,14 +646,13 @@ export function CurrentUserMessageDisplay(props: {
<UserMessageComments comments={comments()} bounded />
</Show>
</div>
<Show when={!menu() && folded()}>
<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. */}
{/* With the message menu, a pending steer still keeps its Pending label and actions in view. */}
<Show when={menu() ? waiting() : props.text || comments().length > 0 || pending()}>
<Show when={props.text || comments().length > 0 || pending()}>
<div data-slot="user-message-copy-wrapper">
<span data-slot="user-message-meta-wrap">
<span data-slot="user-message-meta">
@@ -965,7 +897,6 @@ export function AssistantTextContent(props: {
}) {
const data = useData()
const i18n = useI18n()
const menu = useMessageMenu()
const sourcesHost = useSources()
const numfmt = createMemo(() => new Intl.NumberFormat(i18n.locale()))
// A footer that arrives after the reply rendered belongs to a turn that just finished, so its chip fades in.
@@ -1032,17 +963,7 @@ export function AssistantTextContent(props: {
<div data-slot="text-part-body">
<PacedMarkdown text={props.text} cacheKey={props.id} streaming={props.message.time.completed === undefined} />
</div>
<Show when={menu() && props.showCopy}>
<MessageOptions
info={[
metaText(labels()),
[duration(), interrupted() ? i18n.t("ui.message.interrupted") : ""].filter(Boolean).join(" \u00B7 "),
].filter(Boolean)}
>
<Menu.Item onSelect={() => void copy()}>{i18n.t("ui.message.copyResponse")}</Menu.Item>
</MessageOptions>
</Show>
<Show when={sources()} fallback={<Show when={!menu() && props.showCopy}>{copyFooter()}</Show>}>
<Show when={sources()} fallback={<Show when={props.showCopy}>{copyFooter()}</Show>}>
{(value) => (
<div data-slot="text-part-footer">
<SourcesChip
@@ -1051,7 +972,7 @@ export function AssistantTextContent(props: {
appear={rendered}
onOpen={() => props.onOpenSources?.()}
/>
<Show when={!menu()}>{copyFooter()}</Show>
{copyFooter()}
</div>
)}
</Show>
@@ -25,7 +25,6 @@ import { springStyle } from "@opencode/ui/motion/solid"
import { Dynamic } from "solid-js/web"
import type { SessionUserActions, SessionUserAttachmentReference, SessionUserComment } from "../actions"
import { useData } from "../context"
import { useMessageMenu } from "../context/message-menu"
import { useSources } from "../context/sources"
import { turnSources } from "../sources/sources"
import { TimelineSeparator } from "../components/timeline-separator"
@@ -104,7 +103,6 @@ export function createSessionTimelineRowRenderer(input: {
}) {
const i18n = useI18n()
const data = useData()
const menu = useMessageMenu()
const sourcesHost = useSources()
// Cached timelines retain file-change subgroup identities alongside their disclosure choices.
const patchGroupKeys = input.disclosure.patchGroupKeys ?? new Map<string, string>()
@@ -897,10 +895,9 @@ export function createSessionTimelineRowRenderer(input: {
function TurnGap(props: { messageID: string }) {
let element: HTMLDivElement | undefined
const joined = run(props.messageID)
const turn = () => (menu() ? 24 : 16)
springStyle(
() => element,
() => ({ height: joined()?.above ? 4 : turn() }),
() => ({ height: joined()?.above ? 4 : 16 }),
(el, value) => {
el.style.height = `${value.height}px`
},
@@ -913,7 +910,7 @@ export function createSessionTimelineRowRenderer(input: {
ref={element}
data-timeline-row="TurnGap"
aria-hidden="true"
style={{ height: untrack(joined)?.above ? "4px" : `${untrack(turn)}px` }}
style={{ height: untrack(joined)?.above ? "4px" : "16px" }}
/>
)
}
@@ -1191,8 +1188,10 @@ export function createSessionTimelineRowRenderer(input: {
// A budget pauses paid models, or only the ones it covers, until it resets at the start of a month.
const budgetLimit = createMemo(() => {
const kind = current().kind
if (kind !== "budget-limit" && kind !== "model-budget-limit") return
const at = current().resetAt
const time =
at === undefined
? undefined
@@ -1203,6 +1202,7 @@ export function createSessionTimelineRowRenderer(input: {
minute: "2-digit",
timeZoneName: "shortOffset",
}).format(at)
if (kind === "budget-limit")
return {
text: time
@@ -1210,6 +1210,7 @@ export function createSessionTimelineRowRenderer(input: {
: i18n.t("ui.sessionTurn.budgetLimit.allUntilReset"),
action: i18n.t("ui.sessionTurn.budgetLimit.useFreeModel"),
}
return {
text: time
? i18n.t("ui.sessionTurn.budgetLimit.models", { time })
-1
View File
@@ -285,7 +285,6 @@ const source = {
"ui.message.expand": "Expand message",
"ui.message.collapse": "Collapse message",
"ui.message.copy": "Copy",
"ui.message.options": "Message options",
"ui.message.copyMessage": "Copy message",
"ui.message.copyFullMessage": "Copy full message",
"ui.message.forkMessage": "Fork from here",