Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
fc36cc6
HtmlBlock: add vertical margin so blocks breathe in the chat stream
Cheggin May 26, 2026
3aaa82e
shell:open-external IPC for opening http(s) URLs in the default browser
Cheggin May 26, 2026
7109d75
options block: require url+site, drop decorative images, default allo…
Cheggin May 26, 2026
1383ee3
OptionList: shared-site header w/ favicon, View on {site} button, tal…
Cheggin May 26, 2026
386d1b8
Teach agents to surface dense browser confirmations as HTML
Cheggin May 26, 2026
2bdb2ba
ChosenReceipt: align with picker cards — solid border, favicon, bolde…
Cheggin May 26, 2026
457c773
Keep dark HTML shadows structural
Cheggin May 26, 2026
f86ce1e
OptionList: keep Choose button enabled in single-select after card click
Cheggin May 26, 2026
48bc907
Derive picker/ask submission state from the transcript
Cheggin May 26, 2026
087594c
Harden transcript parsers against ambiguous Other replies
Cheggin May 26, 2026
93d971a
AskForm: strip card chrome and tracked-out labels
Cheggin May 26, 2026
9bdcd99
OptionList: drop outer container chrome to match AskForm
Cheggin May 26, 2026
63048ec
AskForm: theme-aware checkbox checkmark via CSS mask
Cheggin May 26, 2026
cdc94dc
options block: require absolute http(s) URLs for url and image
Cheggin May 26, 2026
f479d33
Apply transcript-derived submission when it arrives after first render
Cheggin May 26, 2026
b755bda
Let transcript-derived state override cache bootstrap
Cheggin May 26, 2026
d658fb1
Re-run transcript hydration after a failed local submit
Cheggin May 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions app/src/main/hl/engines/skillIndexPrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@ export function htmlBlockGuidanceLines(theme: 'light' | 'dark' = 'dark'): string
const p = ACTIVE_PALETTE[theme];
return [
`UI THEME: ${theme}. When you emit a \`\`\`html block, use the active palette below. The full per-theme reference lives in the 'neobrutalist-html' interaction skill.`,
`Active palette — card bg ${p.cardBg}, border ${p.border}, shadow ${p.shadow}, foreground ${p.fg}. Accents: ${p.accents}. Pick one bold accent + one secondary per artifact.`,
'HTML blocks are an optional output channel — use them when layout helps the reader (plans, comparisons, status, timelines, diffs). Conversational replies, tool previews, and short answers should stay as plain markdown.',
`Active palette — card bg ${p.cardBg}, border ${p.border}, shadow ${p.shadow}, foreground ${p.fg}. Accents: ${p.accents}. Use shadow ${p.shadow} for large structural offset shadows; keep accent colors to small highlights, badges, selected metrics, or short dividers.`,
'HTML blocks are an optional output channel — use them when layout helps the reader (plans, comparisons, status, timelines, diffs). Also use them for dense, easily organized browser results or confirmations: shopping/cart/order summaries, delivery windows, addresses, prices, quantities, retailer/site names, reservation details, selected items, and next-step choices. Conversational replies, tool previews, and short answers should stay as plain markdown.',
'Rule of thumb: if you have 3+ concrete facts from the page that naturally fit labeled rows, columns, cards, or a receipt-style summary, emit a compact ```html block instead of burying them in a paragraph.',
'When you emit an HTML block, keep it self-contained: inline styles or a single inline <style> tag are fine; do not reference external stylesheets, scripts, fonts, or images by URL — the sandbox blocks them.',
];
}
Expand All @@ -73,9 +74,12 @@ export const HTML_BLOCK_GUIDANCE_LINES = htmlBlockGuidanceLines('dark');
*/
export function optionsBlockGuidanceLines(): string[] {
return [
'When you need the user to disambiguate between products or options that you can see in the live browser (e.g. shopping search results), emit a ```options fenced block carrying JSON: { prompt, multiSelect, min, max, options: [{ id, image, title, subtitle?, price?, merchant?, url? }] }. Each option requires id, image (absolute URL), and title; the rest are optional.',
'When you need the user to disambiguate between products or options that you can see in the live browser (e.g. shopping search results), emit a ```options fenced block carrying JSON: { prompt, multiSelect, min, max, allowOther?, options: [{ id, image, title, url, site, description?, fields?, subtitle?, price?, merchant? }] }. Each option REQUIRES: id, image (absolute URL), title, url (absolute URL to the source listing), and site. The rest are optional.',
'`image` must be the actual product/listing hero photo, not a badge, favicon, category icon, host avatar, or decorative site asset. For Airbnb, reject `AirbnbPlatformAssets`, `GuestFavorite`, and `orthographic-images` URLs; choose the largest visible listing photo or omit that option.',
'`site` is the brand token a human would call the site — "Amazon", "Instacart", "Airbnb", "Costco" — not the hostname. No TLD, no `.com`, no `.co.uk`. For long-tail sites with no recognizable brand, use the bare second-level domain ("enginediy", "jadecommercecenter"). The renderer derives the favicon mechanically from `url`; `site` is the display label.',
'`allowOther` defaults to false. The renderer no longer shows a free-text "Other" card by default — the chat input below the picker already handles "none of these, I want X". Only set `allowOther: true` when the listed options genuinely aren\'t exhaustive and a typed answer is meaningful.',
'The `options` block ENDS YOUR TURN. After emitting it, do not call any more tools — stop and wait for the user. Their selection arrives as the next user message in the form "Selected from options: <title> (id: <id>)" so you can resume on the same browser session.',
'See the `options-block` interaction skill for the full schema, DOM-extraction snippet for grabbing image URLs / titles / prices from product tiles, and worked examples.',
'See the `options-block` interaction skill for the full schema, DOM-extraction snippet for grabbing image URLs / titles / prices / source URLs from product tiles, and worked examples.',
];
}

Expand Down
45 changes: 36 additions & 9 deletions app/src/main/hl/stock/interaction-skills/neobrutalist-html.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,42 @@
# Neobrutalist HTML — house style

How `\`\`\`html` blocks should LOOK. This skill defines the visual language only.
It does not tell you what structure to build, what elements to use, or when to
emit a block — that's up to you per task. If you're already going to emit HTML,
make it look like this.
How `\`\`\`html` blocks should LOOK. This skill defines the visual language and
basic structure patterns. If the system prompt tells you an answer is a good
HTML candidate, make it look like this.

## Good HTML candidates

Use an HTML block when the output has dense facts that are easy to organize into
labeled rows, columns, cards, or a receipt-style summary. Browser task
confirmations often qualify: selected item, retailer, cart quantity, price,
delivery/pickup window, delivery address, reservation details, order state, and
next-step choices. If you have 3+ concrete facts from the page, a compact HTML
summary is usually easier to scan than a prose paragraph.

Keep genuinely short answers in markdown. Don't emit HTML just to decorate one
sentence.

## Structure patterns

- **Receipt summary:** one headline item, then 4-8 label/value rows for price,
quantity, delivery window, address, retailer, cart state, or confirmation
number.
- **Comparison grid:** 2-6 cards with the same field labels across each card.
- **Status panel:** current state at top, evidence/facts in rows, next actions
at the bottom.

## Mandatory visual rules

- **3px solid #000 borders** on every block-level element you want visible.
Not 1px, not subtle gray. (For nested elements 2px is acceptable.)
- **Hard offset shadow**: `box-shadow: 4px 4px 0 #000;` (no blur, no spread).
- **3px solid palette border** on every block-level element you want visible.
In light mode use black; in dark mode use cream (`#f4ecd8`). Not 1px, not
subtle gray. (For nested elements 2px is acceptable.)
- **Hard offset shadow** using the palette shadow color: black in light mode,
cream (`#f4ecd8`) in dark mode. Example: `box-shadow: 4px 4px 0 #f4ecd8;`.
Larger surfaces can use 6px or 8px. Never soft shadows.
- **No accent-color structural shadows.** Gold/yellow, red, green, blue, pink,
and purple are for small highlights, badges, selected metrics, or short
dividers; they should not become the large outer frame or the dominant
shadow in dark mode.
- **Square corners**: `border-radius: 0`. (4px max is tolerable on small chips
if every chip uses the same value.)
- **Flat color fills** — no gradients, no semi-transparent overlays for
Expand Down Expand Up @@ -71,8 +97,9 @@ palette that matches.

### Two-color rule (both themes)

Pick **one** bold accent + **one** secondary accent per artifact. More than
two accents fights the bold-borders aesthetic.
Pick **one** bold accent + **one** secondary accent per artifact, but keep them
small. More than two accents fights the bold-borders aesthetic, and a large
accent-colored shadow usually overwhelms dark-mode artifacts.

## Typography

Expand Down
11 changes: 11 additions & 0 deletions app/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,13 @@
import { createShellWindow } from './window';
import { createTray, refreshTrayMenu } from './tray';
// Track B — Pill + hotkeys
import { createPillWindow, togglePill, showPill, hidePill, sendToPill, setPillHeight, PILL_HEIGHT_COLLAPSED, PILL_HEIGHT_EXPANDED } from './pill';

Check warning on line 101 in app/src/main/index.ts

View workflow job for this annotation

GitHub Actions / Lint (TS)

'./pill' imported multiple times

Check warning on line 101 in app/src/main/index.ts

View workflow job for this annotation

GitHub Actions / Lint (TS)

'showPill' is defined but never used. Allowed unused vars must match /^_/u
import { createLogsWindow, attachToHub as attachLogsToHub, toggleLogs, hideLogs, getLogsWindow, showLogs, setLogsMode, updateLogsAnchor, focusLogsFollowUp } from './logsPill';
import * as takeoverOverlay from './takeoverOverlay';
import { sendSessionNotification } from './notifications';
import { registerHotkeys, unregisterHotkeys, getGlobalCmdbarAccelerator, setGlobalCmdbarAccelerator } from './hotkeys';
import { makeRequest, PROTOCOL_VERSION } from '../shared/types';

Check warning on line 106 in app/src/main/index.ts

View workflow job for this annotation

GitHub Actions / Lint (TS)

'PROTOCOL_VERSION' is defined but never used. Allowed unused vars must match /^_/u

Check warning on line 106 in app/src/main/index.ts

View workflow job for this annotation

GitHub Actions / Lint (TS)

'makeRequest' is defined but never used. Allowed unused vars must match /^_/u
import type { AgentEvent } from '../shared/types';

Check warning on line 107 in app/src/main/index.ts

View workflow job for this annotation

GitHub Actions / Lint (TS)

'AgentEvent' is defined but never used. Allowed unused vars must match /^_/u
import type { HlEvent } from '../shared/session-schemas';
// Identity
import { AccountStore } from './identity/AccountStore';
Expand Down Expand Up @@ -135,7 +135,7 @@
import { runEngine, DEFAULT_ENGINE_ID } from './hl/engines';
import type { EngineRunControl } from './hl/engines/types';
import { getEngine, setEngine, type EngineId } from './hl/engine';
import { forwardAgentEvent } from './pill';

Check warning on line 138 in app/src/main/index.ts

View workflow job for this annotation

GitHub Actions / Lint (TS)

'./pill' imported multiple times

Check warning on line 138 in app/src/main/index.ts

View workflow job for this annotation

GitHub Actions / Lint (TS)

'forwardAgentEvent' is defined but never used. Allowed unused vars must match /^_/u
// Session management
import { SessionManager } from './sessions/SessionManager';
import { BrowserPool } from './sessions/BrowserPool';
Expand Down Expand Up @@ -2113,6 +2113,17 @@
return process.platform;
});

// Open an absolute http(s) URL in the user's default browser. Used by the
// chat-v2 option picker's "View on {site}" button so the user can verify a
// source listing without leaving the app to switch tabs themselves.
ipcMain.handle('shell:open-external', async (_event, url: unknown) => {
if (typeof url !== 'string' || !/^https?:\/\//.test(url)) {
throw new Error('shell:open-external only accepts http(s) URLs');
}
await shell.openExternal(url);
return { opened: true };
});

// Structured renderer log forwarding — see main/rendererLogIpc.ts and
// renderer/shared/logger.ts. Registered alongside other preload-safe
// channels so the bridge is ready before any window finishes loading.
Expand Down
2 changes: 2 additions & 0 deletions app/src/preload/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
setOverlay: (active: boolean): void => {
ipcRenderer.send('shell:set-overlay', active);
},
openExternal: (url: string): Promise<{ opened: boolean }> =>
ipcRenderer.invoke('shell:open-external', url),
},
pill: {
toggle: (): Promise<void> => ipcRenderer.invoke('pill:toggle'),
Expand Down
1 change: 1 addition & 0 deletions app/src/renderer/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ interface ElectronShellAPI {
platform: string;
getPlatform: () => Promise<string>;
setOverlay: (active: boolean) => void;
openExternal: (url: string) => Promise<{ opened: boolean }>;
}

interface ElectronPillAPI {
Expand Down
117 changes: 110 additions & 7 deletions app/src/renderer/hub/chat-v2/AskForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ interface Props {
complete: boolean;
error?: string;
sessionId?: string;
/** User reply turn that follows this form, if any. Used to reconstruct
* the submitted answers in historical sessions — see OptionList for the
* same pattern. */
nextUserText?: string | null;
}

const OTHER_TOKEN = '__other__';
Expand Down Expand Up @@ -56,7 +60,7 @@ function decodeAskSelection(value: string): { question: string; label: string }
}

export function AskForm(props: Props): React.ReactElement {
const { payload, complete, error, sessionId } = props;
const { payload, complete, error, sessionId, nextUserText } = props;
if (!payload) {
if (complete && error) {
return (
Expand All @@ -67,7 +71,7 @@ export function AskForm(props: Props): React.ReactElement {
}
return <AskFormSkeleton />;
}
return <AskFormReady payload={payload} sessionId={sessionId} streaming={!complete} />;
return <AskFormReady payload={payload} sessionId={sessionId} streaming={!complete} nextUserText={nextUserText} />;
}

function AskFormSkeleton(): React.ReactElement {
Expand All @@ -89,9 +93,10 @@ interface ReadyProps {
payload: AskFormPayload;
sessionId?: string;
streaming?: boolean;
nextUserText?: string | null;
}

function AskFormReady({ payload, sessionId, streaming }: ReadyProps): React.ReactElement {
function AskFormReady({ payload, sessionId, streaming, nextUserText }: ReadyProps): React.ReactElement {
const { questions, prompt } = payload;
const formRef = useRef<HTMLDivElement | null>(null);

Expand All @@ -104,11 +109,20 @@ function AskFormReady({ payload, sessionId, streaming }: ReadyProps): React.Reac
}, [sessionId, questions]);
const cachedRecord = useMemo(() => getSubmissionRecord(cacheKey), [cacheKey]);

// Transcript-derived submission — read the user's next-turn reply for
// an "Answered: …" block and reconstruct selection. Wins over the
// in-memory cache so reopened sessions stay correct without persistence.
const transcriptSubmission = useMemo(
() => deriveAskSubmission(nextUserText, questions),
[nextUserText, questions],
);

// Per-question selected labels. Use `Set<string>` so single + multi
// share the same state shape; "Other" picks store the literal
// OTHER_TOKEN. Per-question typed-other text in a parallel array.
const [selectedByQuestion, setSelectedByQuestion] = useState<Set<string>[]>(
() => questions.map((q) => {
() => questions.map((q, i) => {
if (transcriptSubmission) return new Set(transcriptSubmission.selection[i]);
if (!cachedRecord) return new Set();
const qKey = questionCacheKey(q);
const restored = new Set<string>();
Expand All @@ -123,13 +137,39 @@ function AskFormReady({ payload, sessionId, streaming }: ReadyProps): React.Reac
}),
);
const [otherTextByQuestion, setOtherTextByQuestion] = useState<string[]>(
() => questions.map((q) => cachedRecord?.otherTextByKey?.[questionCacheKey(q)] ?? ''),
() => questions.map((q) => (
transcriptSubmission?.otherTextByKey[questionCacheKey(q)]
?? cachedRecord?.otherTextByKey?.[questionCacheKey(q)]
?? ''
)),
);
const [submitted, setSubmitted] = useState<boolean>(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
transcriptSubmission !== null || cachedRecord !== null,
);
const [submitted, setSubmitted] = useState<boolean>(cachedRecord !== null);
const [submitError, setSubmitError] = useState<string | null>(null);
// Tracks "user clicked Confirm in this mount" vs "bootstrapped from
// cache". Only a local submit makes our state authoritative — a cache
// bootstrap is just a hint and should yield to a later-arriving
// transcript, which is the durable source of truth. State (not a ref)
// so toggling it back after a failed submit re-runs the hydration
// effect to pick up any transcript that was already waiting.
const [localSubmit, setLocalSubmit] = useState<boolean>(false);

const locked = submitted;

// Late-arriving transcript: if nextUserText hydrates after first paint
// (streaming session restore, async transcript fetch), apply the derived
// submission. Skip only when the user has submitted locally in this
// mount — bootstrap-from-cache must not block transcript hydration.
useEffect(() => {
if (!transcriptSubmission || localSubmit) return;
setSelectedByQuestion(questions.map((_, i) => new Set(transcriptSubmission.selection[i])));
setOtherTextByQuestion(questions.map((q) => (
transcriptSubmission.otherTextByKey[questionCacheKey(q)] ?? ''
)));
setSubmitted(true);
}, [transcriptSubmission, localSubmit, questions]);

const togglePick = useCallback((qIdx: number, label: string): void => {
const q = questions[qIdx];
if (!q) return;
Expand Down Expand Up @@ -192,13 +232,15 @@ function AskFormReady({ payload, sessionId, streaming }: ReadyProps): React.Reac
return;
}
const message = formatAnswerMessage(questions, selectedByQuestion, otherTextByQuestion);
setLocalSubmit(true);
setSubmitted(true);
setSubmitError(null);
try {
const result = await window.electronAPI?.sessions?.resume(sessionId, message);
if (result?.error) {
setSubmitError(result.error);
setSubmitted(false);
setLocalSubmit(false);
} else {
// Persist enough to restore submitted view on remount.
const flat: string[] = [];
Expand All @@ -214,6 +256,7 @@ function AskFormReady({ payload, sessionId, streaming }: ReadyProps): React.Reac
} catch (err) {
setSubmitError((err as Error).message);
setSubmitted(false);
setLocalSubmit(false);
}
}, [canSubmit, locked, sessionId, questions, selectedByQuestion, otherTextByQuestion, cacheKey]);

Expand Down Expand Up @@ -313,7 +356,6 @@ function QuestionCard({ question, selected, otherText, locked, onToggle, onOther
return (
<div className="chatv2-askform__question">
<div className="chatv2-askform__question-head">
{question.header && <span className="chatv2-askform__question-header">{question.header}</span>}
<span className="chatv2-askform__question-text">{question.question}</span>
</div>
<ul className="chatv2-askform__options" role={question.multiSelect ? 'group' : 'radiogroup'}>
Expand Down Expand Up @@ -360,6 +402,67 @@ function QuestionCard({ question, selected, otherText, locked, onToggle, onOther
);
}

/**
* Reverse of formatAnswerMessage: parse the user-reply turn that follows
* this form and reconstruct which options were chosen per question.
* Returns null when the text isn't an "Answered: …" reply for this form.
*
* Exported for tests.
*/
export function deriveAskSubmission(
text: string | null | undefined,
questions: AskQuestion[],
): { selection: Set<string>[]; otherTextByKey: Record<string, string> } | null {
if (!text) return null;
const head = text.trimStart();
if (!head.startsWith('Answered:')) return null;

const selection: Set<string>[] = questions.map(() => new Set<string>());
const otherTextByKey: Record<string, string> = {};

for (const rawLine of text.split('\n')) {
const m = rawLine.match(/^-\s*([^:]+):\s*(.+)$/);
if (!m) continue;
const labelPrefix = m[1].trim();
const valuesStr = m[2].trim();
const qIdx = questions.findIndex((q) => (q.header || q.question) === labelPrefix);
if (qIdx < 0) continue;

// Values are comma-separated. A free-text `Other: <text>` answer can
// itself contain commas, so we can't blindly split. Strategy: find
// ", Other:" (or a leading "Other:") and treat everything from there
// to the end of the line as a single Other value. The remainder is
// safe to split on /,\s+/ because predefined option labels are
// controlled by the agent and don't carry user free text.
let valuesPart = valuesStr;
let otherTail: string | null = null;
const otherIdx = (() => {
const leading = valuesPart.match(/^Other(?::|$)/);
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot May 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The new Other parsing assumes Other is the final value, so when Other appears first (a valid serialized order), later options are lost during transcript reconstruction.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/renderer/hub/chat-v2/AskForm.tsx, line 418:

<comment>The new `Other` parsing assumes `Other` is the final value, so when `Other` appears first (a valid serialized order), later options are lost during transcript reconstruction.</comment>

<file context>
@@ -406,19 +406,33 @@ export function deriveAskSubmission(
+    let valuesPart = valuesStr;
+    let otherTail: string | null = null;
+    const otherIdx = (() => {
+      const leading = valuesPart.match(/^Other(?::|$)/);
+      if (leading) return 0;
+      const m = valuesPart.match(/,\s+Other(?::|$)/);
</file context>
Fix with Cubic

if (leading) return 0;
const m = valuesPart.match(/,\s+Other(?::|$)/);
return m && m.index !== undefined ? m.index + m[0].indexOf('Other') : -1;
})();
if (otherIdx >= 0) {
otherTail = valuesPart.slice(otherIdx);
valuesPart = valuesPart.slice(0, otherIdx).replace(/,\s*$/, '');
}
for (const raw of valuesPart.split(/,\s+/)) {
const v = raw.trim();
if (!v) continue;
selection[qIdx].add(v);
}
if (otherTail !== null) {
selection[qIdx].add(OTHER_TOKEN);
if (otherTail.startsWith('Other:')) {
otherTextByKey[questionCacheKey(questions[qIdx])] = otherTail.slice('Other:'.length).trim();
}
}
}

if (selection.every((s) => s.size === 0)) return null;
return { selection, otherTextByKey };
}

function formatAnswerMessage(
questions: AskQuestion[],
selectedByQuestion: Set<string>[],
Expand Down
Loading
Loading