From 5d5cbc29aa75692510e91a7310827ecae1599676 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 11 Oct 2026 09:37:52 +0000 Subject: [PATCH 1/2] feat(chat): show passive-context wrappers as a labelled nested card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passive tool context wrapped in a tag (graphify's , omp's rule reminders) lost the tag name and attribute keys, so it read as unlabelled text. Title it like developer reminders (system-reminder · source=graphify) on the collapsed line, and render it as a nested card with that header when the tool card is expanded. --- CHANGELOG.md | 1 + app/globals.css | 18 ++++++++++++++++++ components/MessageView.test.mjs | 19 +++++++++++++------ components/MessageView.tsx | 29 ++++++++++++++++++++--------- 4 files changed, 52 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dff5994c4..29253fedd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f ### Fixes & Improvements +- **Passive tool context shows its wrapper.** Context wrapped in a tag such as `` now names the tag and every attribute on the tool card's `↳ Context (system-reminder · source=graphify): …` line. Expanding the card shows the context as a small nested card with that header and the body below, like developer reminders. Before, the tag name and attribute keys were dropped, so a graphify nudge looked like unlabelled text. - `ompweb hash-password` in a terminal finishes when you press Enter. The hidden prompt waited for a newline, but a terminal in raw mode sends Enter as a carriage return, so the prompt never ended. - The Notifications tab now tells unread notifications from read ones: an unread notification has an accent dot before it and a bold title on a tinted row, and read notifications are dimmed. The tab also lists only the notifications that can also be pushed (task finished, input needed, error, model switch). Confirmations such as "Saved", copy results, update notices and agent `notify` calls still show as toasts but no longer fill the tab. A session's next notification of the same type is unread again instead of inheriting the earlier one's read state. - Fit the agent's question panel on a phone. A long question, header or option preview no longer widens the panel past the screen and clips the answer text at the right edge, and previews wrap instead of scrolling sideways. diff --git a/app/globals.css b/app/globals.css index b0471759c..b36f7fe23 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3934,6 +3934,24 @@ html[data-touch-targets="accessible"] .right-panel-toolbar { line-height: 1.4; overflow-wrap: anywhere; } +.tool-call-context-card { + border: 1px solid var(--border); + border-radius: var(--radius-control); + overflow: hidden; + background: var(--bg); +} +.tool-call-context-title { + padding: 4px 8px; + border-bottom: 1px solid var(--border); + background: var(--bg-panel); + color: var(--text-muted); + font-family: var(--font-mono); + font-weight: 650; +} +.tool-call-context-body { + padding: 5px 8px; + color: var(--text-muted); +} .tool-call-output { max-height: 260px; overflow: auto; diff --git a/components/MessageView.test.mjs b/components/MessageView.test.mjs index 0999f61a4..0971deba5 100644 --- a/components/MessageView.test.mjs +++ b/components/MessageView.test.mjs @@ -574,17 +574,24 @@ test("passive tool context from a session file renders on the batch's last tool const segment = view.container.querySelector(".tool-call-details .tool-call-context"); assert.equal(segment?.textContent, "↳ Context: Treat\tthis result\nas authoritative."); assert.equal(view.container.querySelector(".activity-row-secondary[title]"), null, "no truncated row while expanded"); - // A harness wrapper (omp's rule reminders) becomes a label instead of raw tag text. + // A harness wrapper shows as a label, titled like a developer message, never as raw tag text. const wrapped = new Map(toolResults); wrapped.set("call-2", { ...toolResults.get("call-2"), passiveContext: '\nUse a Record.\n' }); const labeled = renderToStaticMarkup(React.createElement(MessageView, { message: single, toolResults: wrapped })); - assert.match(labeled, /↳ <\/span>Context \(rule_violation · ts-set-map\): Use a Record\.<\/div>/); - assert.doesNotMatch(labeled, /system-reminder/); - // The length cap can cut the closing tag; the opening tag still never leaks. + assert.match(labeled, /↳ <\/span>Context \(system-reminder · reason=rule_violation · rule=ts-set-map\): Use a Record\.<\/div>/); + assert.doesNotMatch(labeled, /</); + // The length cap can cut the closing tag; the opening tag still never leaks, and empty values drop out. wrapped.set("call-2", { ...toolResults.get("call-2"), passiveContext: '\nUse a Rec' }); const cut = renderToStaticMarkup(React.createElement(MessageView, { message: single, toolResults: wrapped })); - assert.match(cut, /↳ <\/span>Context \(ts-set-map\): Use a Rec<\/div>/); - assert.doesNotMatch(cut, /system-reminder/); + assert.match(cut, /↳ <\/span>Context \(system-reminder · rule=ts-set-map\): Use a Rec<\/div>/); + assert.doesNotMatch(cut, /<|reason=/); + // Expanded, the wrapper becomes a nested card: its title as the header, the body below. + wrapped.set("call-2", { ...toolResults.get("call-2"), passiveContext: '\nMANDATORY: run `graphify query`.\n' }); + const card = render(React.createElement(MessageView, { message: single, toolResults: wrapped })); + fireEvent.click(card.getAllByRole("button", { expanded: false }).at(-1)); + const nested = card.container.querySelector(".tool-call-details .tool-call-context-card"); + assert.equal(nested?.querySelector(".tool-call-context-title")?.textContent, "↳ system-reminder · source=graphify"); + assert.equal(nested?.querySelector(".tool-call-context-body")?.textContent, "MANDATORY: run `graphify query`."); // The marked message is no row of its own; the unmarked one still is. assert.equal(messages[4].customType, "passive-tool-context"); assert.equal(html(messages[4]), ""); diff --git a/components/MessageView.tsx b/components/MessageView.tsx index 80dd0ac51..f382f9cde 100644 --- a/components/MessageView.tsx +++ b/components/MessageView.tsx @@ -979,6 +979,7 @@ const ToolCallBlock = memo(function ToolCallBlock({ .map((b) => b.text) .join("\n")) : null; + const passiveWrapper = result?.passiveContext ? parseLeadingWrapper(result.passiveContext) : null; const passiveContextText = result?.passiveContext ? contextLine(result.passiveContext, t) : null; const resultImages = result && Array.isArray(result.content) ? result.content.filter((b): b is ImageContent => b.type === "image") @@ -1195,7 +1196,15 @@ const ToolCallBlock = memo(function ToolCallBlock({ ) : null} {passiveContextText && (
- ↳ {passiveContextText} + {passiveWrapper ? ( + // Nested like a developer message: the wrapper as a header, its body below. +
+
↳ {wrapperTitle(passiveWrapper)}
+
{passiveWrapper.body}
+
+ ) : ( + <>↳ {passiveContextText} + )}
)} @@ -1317,14 +1326,17 @@ function parseLeadingWrapper(text: string): { tag: string; attrs: [string, strin }; } -/** "Context (rule_violation · ts-set-map · …): body" for a wrapped passive context, plain otherwise. */ +/** `` → "system-reminder · reason=… · rule=…"; empty values are skipped. */ +function wrapperTitle(wrapper: { tag: string; attrs: [string, string][] }): string { + return [wrapper.tag, ...wrapper.attrs.filter(([, value]) => value).map(([key, value]) => `${key}=${value}`)].join(" · "); +} + +/** "Context (system-reminder · rule=ts-set-map · …): body" for a wrapped passive context, plain otherwise. */ function contextLine(text: string, t: (key: string, params?: Record) => string): string { const wrapper = parseLeadingWrapper(text); - const label = wrapper?.attrs.map(([, value]) => value).filter(Boolean).join(" · "); - const body = wrapper ? wrapper.body : text; - return label - ? t("messageView.passiveContextLabeled", { label, text: body }) - : t("messageView.passiveContext", { text: body }); + return wrapper + ? t("messageView.passiveContextLabeled", { label: wrapperTitle(wrapper), text: wrapper.body }) + : t("messageView.passiveContext", { text }); } function friendlyHiddenLabel(customType: string, t: (key: string) => string): string { @@ -1559,12 +1571,11 @@ function CustomMessageView({ message, cwd, onOpenFile }: { message: CustomMessag // them verbatim. const isPlainText = message.customType === "async-result" || message.customType === "lsp-late-diagnostic"; const displayText = ircEnvelope ? ircEnvelope.body : isPlainText || isDeveloper ? stripHiddenWrappers(text) : text; - // `` → "system-reminder · reason=… · rule=…". const wrapper = isDeveloper ? parseLeadingWrapper(text) : null; const firstLine = displayText.split("\n").find((line) => line.trim())?.trim() ?? ""; const collapsedPreview = firstLine && firstLine !== displayText.trim() ? `${firstLine} …` : firstLine; const title = wrapper - ? [wrapper.tag, ...wrapper.attrs.map(([key, value]) => `${key}=${value}`)].join(" · ") + ? wrapperTitle(wrapper) : isIrc ? (ircEnvelope?.sender ?? formatCustomType(message.customType)) : message.customType === "advisor" From 767fe20a051867dba24ca9e7fd9af049b8a9def8 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sun, 11 Oct 2026 09:39:33 +0000 Subject: [PATCH 2/2] refactor(chat): parse the passive-context wrapper once; skip an empty body --- components/MessageView.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/components/MessageView.tsx b/components/MessageView.tsx index f382f9cde..c3824f8eb 100644 --- a/components/MessageView.tsx +++ b/components/MessageView.tsx @@ -980,7 +980,7 @@ const ToolCallBlock = memo(function ToolCallBlock({ .join("\n")) : null; const passiveWrapper = result?.passiveContext ? parseLeadingWrapper(result.passiveContext) : null; - const passiveContextText = result?.passiveContext ? contextLine(result.passiveContext, t) : null; + const passiveContextText = result?.passiveContext ? contextLine(result.passiveContext, passiveWrapper, t) : null; const resultImages = result && Array.isArray(result.content) ? result.content.filter((b): b is ImageContent => b.type === "image") : []; @@ -1200,7 +1200,7 @@ const ToolCallBlock = memo(function ToolCallBlock({ // Nested like a developer message: the wrapper as a header, its body below.
↳ {wrapperTitle(passiveWrapper)}
-
{passiveWrapper.body}
+ {passiveWrapper.body &&
{passiveWrapper.body}
}
) : ( <>↳ {passiveContextText} @@ -1332,8 +1332,11 @@ function wrapperTitle(wrapper: { tag: string; attrs: [string, string][] }): stri } /** "Context (system-reminder · rule=ts-set-map · …): body" for a wrapped passive context, plain otherwise. */ -function contextLine(text: string, t: (key: string, params?: Record) => string): string { - const wrapper = parseLeadingWrapper(text); +function contextLine( + text: string, + wrapper: { tag: string; attrs: [string, string][]; body: string } | null, + t: (key: string, params?: Record) => string, +): string { return wrapper ? t("messageView.passiveContextLabeled", { label: wrapperTitle(wrapper), text: wrapper.body }) : t("messageView.passiveContext", { text });