Skip to content

Commit fa2af74

Browse files
phodalQoder-AI
andcommitted
feat(studio): configure agents before live comparison
Prepare selected ACP agent sessions in the Compare workspace so readers can review and change each configuration before submitting one shared task. Keep retained-session comparison evidence explicit rather than choosing a default pair, with browser coverage for the updated input flow. Spec: docs/specs/2026-09-09-compare-input-flow.md Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com>
1 parent 901d44b commit fa2af74

16 files changed

Lines changed: 304 additions & 138 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Compare input flow
2+
3+
## Traceability
4+
- Spec ID: compare-input-flow
5+
- Status: Implemented
6+
- Request: simplify the Compare composer, make Agent configuration inline, and keep retained Session comparison explicit.
7+
8+
## Intent
9+
Keep Compare focused on one decision at a time: select Agents, review their offered configuration in the persistent input region, then run. Do not load or open retained Sessions until the reader explicitly selects a pair for comparison.
10+
11+
## Acceptance Scenarios
12+
- AC-1: The idle Compare surface shows only the docked input region and its neutral reading area; it does not render saved conversation/chat content. Readiness remains an inline status/control and never consumes a separate full-width line.
13+
- AC-2: Choosing one or more available Agents automatically prepares their configuration in the input region. Offered core options, including reasoning strength when the Agent supplies it, are editable before the shared prompt starts.
14+
- AC-3: Choosing `Configure Agents` refreshes only the input-region configuration. It does not reveal a transcript, create message panes, or navigate away from Compare.
15+
- AC-4: Starting the prompt from a prepared input region preserves the current prompt and Agent configuration, then opens the existing live comparison lanes.
16+
- AC-5: Compare defaults to Live Agents. Its retained Sessions surface remains empty until the reader explicitly selects two Sessions in the Sessions workspace; it then requests only that pair's comparison, without loading or auto-pairing the retained Session catalog.
17+
- AC-6: Entering the Sessions workspace does not automatically fetch a Session detail; explicit row selection remains the only detail-opening action.
18+
19+
## Non-goals
20+
Do not add Agent adapters, invent configuration options, alter retained Session discovery, change the live comparison evidence panes after a prompt starts, or modify release metadata.
21+
22+
## Plan and Tasks
23+
1. Keep ACP preparation streams in Compare state, expose `AcpSessionSettings` inside the composer, and release them only when the reader starts the prompt.
24+
2. Allow prepared ACP runs to replace their initial prompt at start time, so Agent selection can precede prompt entry.
25+
3. Remove idle conversation history and make composer status/configuration wrap within its own bounded surface.
26+
4. Default Compare to Live Agents; make retained Session comparison pair-driven and remove automatic Session detail opening.
27+
5. Update English/Chinese labels and focused browser tests for configuration, Session scope, keyboard focus, wide/compact/narrow overflow, and browser errors.
28+
29+
## Test and Review Evidence
30+
- AC-1/AC-3: Browser tests assert no idle transcript or lanes while configuration refreshes in the composer.
31+
- AC-2/AC-4: ACP fixture tests change model/reasoning settings, start after preparation, and verify the selected values reach the Agent.
32+
- AC-5/AC-6: Browser coverage verifies the legacy Sessions catalog has no selected detail after reload and opens evidence only after an explicit row click; retained comparison still starts from an explicitly selected pair.
33+
- All ACs: run Studio typecheck/build, relevant Vitest/Playwright suites, `git diff --check`, and visual screenshots at 1440px, 1024px, and 390px with console/page-error checks.
34+
- Verified: Studio build/typecheck, 19 focused unit assertions, and six focused Playwright scenarios covering inline configuration, input-only refresh, explicit Session opening, live lane startup, responsive layouts, keyboard focus, and browser errors passed locally.
35+
- Risk: preparation streams remain live until start or refresh; reset must close old sessions, retain the latest requested Agent set, and never expose stale transcript content.

‎packages/harness-studio/src/app/App.tsx‎

Lines changed: 26 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,9 @@ export function App(): React.JSX.Element {
218218
const [bootstrapRevision, setBootstrapRevision] = useState(0);
219219
const [area, setArea] = useState<StudioArea>(areaFromHash);
220220
const [locationRevision, setLocationRevision] = useState(0);
221-
const [compareSurface, setCompareSurface] = useState<StudioCompareSurface>("sessions");
221+
// Live comparison is the active workflow. Retained Session evidence is only
222+
// opened after the reader explicitly selects a pair in Sessions.
223+
const [compareSurface, setCompareSurface] = useState<StudioCompareSurface>("live");
222224
const [navigationOpen, setNavigationOpen] = useState(false);
223225
const [sidebarCollapsed, setSidebarCollapsed] = useState(storedSidebarCollapsed);
224226
const [sidebarWidth, setSidebarWidth] = useState(storedSidebarWidth);
@@ -394,7 +396,7 @@ export function App(): React.JSX.Element {
394396
setProjects(loaded.projectCatalog.projects);
395397
setActiveProjectId(loaded.projectCatalog.activeProjectId);
396398
setConfig(loaded.config);
397-
setCompareSurface((currentSurface) => compareSurfaces(loaded.config).includes(currentSurface) ? currentSurface : compareSurfaces(loaded.config)[0] ?? "sessions");
399+
setCompareSurface((currentSurface) => compareSurfaces(loaded.config).includes(currentSurface) ? currentSurface : compareSurfaces(loaded.config)[0] ?? "live");
398400
setDataRevision((revision) => revision + 1);
399401
} catch (error) {
400402
setConfigFailure(error instanceof Error ? error.message : t("config.sourceSwitchFailed"));
@@ -410,7 +412,7 @@ export function App(): React.JSX.Element {
410412
setConfig(loaded.config);
411413
setSessionCompareIds(undefined);
412414
setSessionOpenId(undefined);
413-
setCompareSurface((currentSurface) => compareSurfaces(loaded.config).includes(currentSurface) ? currentSurface : compareSurfaces(loaded.config)[0] ?? "sessions");
415+
setCompareSurface((currentSurface) => compareSurfaces(loaded.config).includes(currentSurface) ? currentSurface : compareSurfaces(loaded.config)[0] ?? "live");
414416
setWorkspaceRevision((revision) => revision + 1);
415417
return loaded.projectCatalog.activeProjectId;
416418
}
@@ -932,8 +934,11 @@ function SessionsWorkspace(props: {
932934
if (cancelled) return;
933935
setOmittedCount(payload.workspace.omittedCount);
934936
setSessions(payload.sessions);
935-
const inRange = payload.sessions.filter((session) => withinDateRange(session.savedAt, props.dateRange));
936-
const initialSession = inRange.find((session) => session.id === props.initialSessionId) ?? inRange[0];
937+
// Opening Sessions presents an evidence catalog, not an implicit detail
938+
// navigation. Only an explicit deep-link/session request opens a row.
939+
const initialSession = props.initialSessionId === undefined
940+
? undefined
941+
: payload.sessions.find((session) => session.id === props.initialSessionId && withinDateRange(session.savedAt, props.dateRange));
937942
if (initialSession !== undefined) await openSession(initialSession.id, () => cancelled);
938943
} catch (error) {
939944
if (!cancelled) setFailure(error instanceof Error ? error.message : String(error));
@@ -1279,53 +1284,26 @@ interface SessionComparison {
12791284
right: SessionComparisonSide;
12801285
}
12811286

1282-
/**
1283-
* Seed the pair with two different Agents when the Project has them, so the
1284-
* default view answers the cross-Agent question instead of pairing whichever
1285-
* two Sessions happen to be newest.
1286-
*/
1287-
function crossAgentPair(sessions: readonly SessionSummary[]): [string, string] | undefined {
1288-
const first = sessions[0];
1289-
if (first === undefined) return undefined;
1290-
const other = sessions.find((session) => (session.provider ?? "") !== (first.provider ?? ""));
1291-
return other === undefined ? undefined : [first.id, other.id];
1292-
}
1293-
12941287
function SessionCompareView(props: { navigation: ReactNode; initialIds?: [string, string] }): React.JSX.Element {
12951288
const { t } = useTranslation("sessions");
1296-
const [sessions, setSessions] = useState<SessionSummary[]>([]);
1297-
const [leftId, setLeftId] = useState(props.initialIds?.[0] ?? "");
1298-
const [rightId, setRightId] = useState(props.initialIds?.[1] ?? "");
12991289
const [comparison, setComparison] = useState<SessionComparison>();
13001290
const [failure, setFailure] = useState<string>();
1291+
const leftId = props.initialIds?.[0];
1292+
const rightId = props.initialIds?.[1];
13011293

13021294
useEffect(() => {
1303-
let cancelled = false;
1304-
void (async () => {
1305-
try {
1306-
const response = await fetch("api/sessions");
1307-
if (!response.ok) throw new Error(await studioApiError(response));
1308-
const loaded = await response.json() as { sessions: SessionSummary[] };
1309-
if (cancelled) return;
1310-
setSessions(loaded.sessions);
1311-
const preferred = crossAgentPair(loaded.sessions);
1312-
setLeftId((current) => current || preferred?.[0] || loaded.sessions[0]?.id || "");
1313-
setRightId((current) => current || preferred?.[1] || loaded.sessions[1]?.id || "");
1314-
} catch (error) {
1315-
if (!cancelled) setFailure(error instanceof Error ? error.message : String(error));
1316-
}
1317-
})();
1318-
return () => { cancelled = true; };
1319-
}, []);
1320-
1321-
useEffect(() => {
1322-
if (leftId === "" || rightId === "" || leftId === rightId) return;
1295+
if (leftId === undefined || rightId === undefined || leftId === rightId) {
1296+
setComparison(undefined);
1297+
setFailure(undefined);
1298+
return;
1299+
}
13231300
const controller = new AbortController();
1301+
setComparison(undefined);
1302+
setFailure(undefined);
13241303
void (async () => {
13251304
try {
13261305
const response = await fetch(`api/session-compare?${new URLSearchParams({ left: leftId, right: rightId })}`, { signal: controller.signal });
13271306
if (!response.ok) throw new Error(await studioApiError(response));
1328-
setFailure(undefined);
13291307
setComparison(await response.json() as SessionComparison);
13301308
} catch (error) {
13311309
if (!controller.signal.aborted) setFailure(error instanceof Error ? error.message : String(error));
@@ -1334,10 +1312,15 @@ function SessionCompareView(props: { navigation: ReactNode; initialIds?: [string
13341312
return () => controller.abort();
13351313
}, [leftId, rightId]);
13361314

1337-
const optionLabel = (session: SessionSummary): string => t("compare.optionLabel", { agent: session.provider ?? t("common:localAgent"), prompt: session.prompt });
1315+
if (leftId === undefined || rightId === undefined || leftId === rightId) {
1316+
return <main className="session-compare-workspace">
1317+
<header><div><small>{t("compare.eyebrow")}</small><h1>{t("compare.title")}</h1></div>{props.navigation}</header>
1318+
<div className="session-compare-empty"><h2>{t("compare.selectPairTitle")}</h2><p>{t("compare.selectPairDetail")}</p></div>
1319+
</main>;
1320+
}
1321+
13381322
return <main className="session-compare-workspace">
13391323
<header><div><small>{t("compare.eyebrow")}</small><h1>{t("compare.title")}</h1></div>{props.navigation}</header>
1340-
<div className="session-compare-picker"><label><span>{t("compare.left")}</span><select value={leftId} onChange={(event) => setLeftId(event.target.value)}>{sessions.map((session) => <option key={session.id} value={session.id} disabled={session.id === rightId}>{optionLabel(session)}</option>)}</select></label><label><span>{t("compare.right")}</span><select value={rightId} onChange={(event) => setRightId(event.target.value)}>{sessions.map((session) => <option key={session.id} value={session.id} disabled={session.id === leftId}>{optionLabel(session)}</option>)}</select></label></div>
13411324
{failure !== undefined && <p className="session-compare-boundary status-danger" role="alert">{failure}</p>}
13421325
{comparison === undefined ? <p className="artifact-status" role="status">{t("compare.loading")}</p> : <>
13431326
<p className="session-compare-boundary"><strong>{t("compare.noWinner")}</strong> {comparison.boundary}</p>

0 commit comments

Comments
 (0)