Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
83 commits
Select commit Hold shift + click to select a range
b374627
[DOCS]: Initial GUI Tree Plan
spencrr Jun 10, 2026
ac9017e
docs(tree-ui): design rev-18 baseline for V1.0 implementation
spencrr Jun 10, 2026
d9316bc
feat(backend): relocate _validate_operator_match to AttackResult.labels
spencrr Jun 10, 2026
6144ecc
feat(backend): expose original_prompt_id + converter_identifiers on M…
spencrr Jun 10, 2026
550c88e
feat(frontend): extend API types for tree-UI prepended_conversation +…
spencrr Jun 10, 2026
793798a
feat(frontend): introduce tree-UI domain types + runner interfaces
spencrr Jun 10, 2026
81ba1a1
feat(frontend): runner readiness layer + topological-walk primitives …
spencrr Jun 10, 2026
c8f335d
refactor(frontend): tighten runner test helpers per rubber-duck revie…
spencrr Jun 10, 2026
d499dad
test(frontend): trim contract-test boilerplate + gate satisfies in CI…
spencrr Jun 10, 2026
5e79281
feat(frontend): resolvePathPartition — clean-prefix / fresh-suffix wa…
spencrr Jun 10, 2026
1656925
feat(frontend): dispatch helpers — buildLabels + formatApiError (PR4c1)
spencrr Jun 10, 2026
73c75d0
feat(frontend): leaf dispatch orchestrator — create_attack + N add_me…
spencrr Jun 10, 2026
c9cade4
feat(frontend): wave dispatch loop with cascade + cancel (PR4d)
spencrr Jun 10, 2026
2cc6d78
fix(frontend): drop clean-prefix optimization; ship dumb-but-correct …
spencrr Jun 10, 2026
8caac33
refactor(frontend): rubber-duck cleanup of runner internals (PR4d.2)
spencrr Jun 10, 2026
dba5f7b
feat(frontend): 5-step entry-point shim + wave-end reconcile (PR4e)
spencrr Jun 10, 2026
4ce1da6
feat(frontend): real BroadcastChannel cross-tab lock + queue drain se…
spencrr Jun 10, 2026
be48422
refactor(frontend): rubber-duck cleanup of runner core PR4e+f.1
spencrr Jun 11, 2026
787552c
feat(frontend): react-flow scaffold + conversationTreeToReactFlow ada…
spencrr Jun 11, 2026
15f0f42
feat(frontend): per-kind node card components (PR5b)
spencrr Jun 11, 2026
38b30df
refactor(frontend): rubber-duck cleanup of UI scaffold PR5a+b.1
spencrr Jun 11, 2026
ce4abc6
feat(frontend): per-node action rail — common actions wired via conte…
spencrr Jun 11, 2026
d485521
feat(frontend): per-edge insert chip + kind-aware popover (PR5d)
spencrr Jun 11, 2026
5bb0e49
feat(frontend): Fan-Children Stack rendering (PR5e)
spencrr Jun 11, 2026
6dc58d7
feat(frontend): Pick / Unpick fan children (PR5f)
spencrr Jun 11, 2026
3a8495b
feat(frontend): Buchheim-Walker tree layout via d3-hierarchy (PR5g)
spencrr Jun 11, 2026
956bcf9
refactor(frontend): split adapter from collapse + memoize layout on s…
spencrr Jun 11, 2026
28c82d5
refactor(frontend): discriminated InsertMenuOption for V1.1 stub safe…
spencrr Jun 11, 2026
04bca89
test(frontend): strengthen vacuous-pass guards in edge-insert tests (…
spencrr Jun 11, 2026
cd86f20
feat(frontend): hover/selected gate for action rail visibility (PR5h.…
spencrr Jun 11, 2026
672fe77
feat(frontend): UserTurn inline edit affordance (PR5h.5)
spencrr Jun 11, 2026
dbf4180
feat(frontend): RootPrompt edit prompt + system + target affordance (…
spencrr Jun 11, 2026
795c1d2
feat(frontend): UserTurn ⚡ converter palette affordance (PR5h.7)
spencrr Jun 11, 2026
ebfcc7d
chore(frontend): toFlowEdge silent-default fix + test rigor cleanups …
spencrr Jun 11, 2026
d5622d5
refactor(frontend): useEditorKeyboard hook for uniform Esc/Cmd-Enter …
spencrr Jun 11, 2026
709fa46
feat(frontend): per-converter remove chips on UserTurnCard (PR5h.10 r…
spencrr Jun 11, 2026
5be6f6d
perf(frontend): memoize actionCallbacks at TreeCanvas boundary (PR5h.…
spencrr Jun 11, 2026
e233413
refactor(frontend): decompose nodeCards.tsx into per-kind files (PR5h…
spencrr Jun 11, 2026
3c45a93
feat(frontend): cost-guardrail confirmation modal (PR6a)
spencrr Jun 11, 2026
3000557
feat(frontend): cost-preview tooltip on Refresh button (PR6b)
spencrr Jun 11, 2026
7d1822e
feat(frontend): wave-status ribbon + cancel chip (PR6c)
spencrr Jun 11, 2026
9f50cf1
feat(frontend): wave-complete toast with 5-bucket summary + Retry fai…
spencrr Jun 12, 2026
fceaaee
feat(frontend): per-node Past runs drawer with pin / checkout (PR6e)
spencrr Jun 12, 2026
455ce85
fix(frontend): track per-tree queue depth across active-wave swap (PR…
spencrr Jun 12, 2026
a94929b
fix(frontend): reset wave-complete toast auto-dismiss timer when summ…
spencrr Jun 12, 2026
34e6e7b
feat(frontend): wait-for-rate-limit tooltip on disabled Retry button …
spencrr Jun 12, 2026
8ce61d3
fix(frontend): sync-reject concurrent cost-guardrail approvals (PR6.4…
spencrr Jun 12, 2026
3245f29
chore(frontend): PR6.5 review polish — UUID truncation + modal body g…
spencrr Jun 12, 2026
e6b75fe
test(frontend): close PR6 coverage gaps to clear 85% branch (PR6.6 re…
spencrr Jun 12, 2026
11d5987
feat(frontend): auto-reverse pure functions for AR→tree reconstructio…
spencrr Jun 12, 2026
05e05c9
feat(frontend): TreeRunnerHost layout-only shell (PR7b)
spencrr Jun 12, 2026
ad08821
feat(frontend): pure tree-state reducer for sink → React-state bridge…
spencrr Jun 12, 2026
a886e25
feat(frontend): TreeRunnerHost wires runner shim + WaveEvent buffer (…
spencrr Jun 12, 2026
3b36b05
feat(frontend): wire useCostGuardrailModal into TreeRunnerHost (PR7d)
spencrr Jun 12, 2026
47e8139
feat(frontend): useAutoReverse hook for AR → tree reconstruction (PR7e)
spencrr Jun 12, 2026
f878e24
feat(frontend): workspace-persistence pure helpers (PR7f.1)
spencrr Jun 12, 2026
97946d4
feat(frontend): wire workspace persistence hook into TreeRunnerHost (…
spencrr Jun 12, 2026
626f178
feat(frontend): reload reconstruction from fragment tree id (PR7g, sl…
spencrr Jun 12, 2026
66f0b0c
feat(frontend): in-app dirty-edit swap guard modal (PR7h)
spencrr Jun 12, 2026
7142c22
feat(frontend): production runWaveStarter adapter (PR7i.1)
spencrr Jun 12, 2026
e6580b0
feat(frontend): mount TreeRunnerHost in App behind VITE_ENABLE_TREE_U…
spencrr Jun 12, 2026
879c0be
fix(frontend): bound WaveEvent buffer via compact-on-idle (PR7 review)
spencrr Jun 12, 2026
1ca79d6
fix(frontend): auto-cancel prior tree's wave on tree swap (PR7 review)
spencrr Jun 12, 2026
d23ccf2
fix(frontend): disclose linear-only reload degradation via operator b…
spencrr Jun 12, 2026
cd80665
chore(frontend): wire reflogCapPerNode + clarify member_slot_indices …
spencrr Jun 12, 2026
38286f4
feat(frontend): fan-aware reload reconstruction for root-level attemp…
spencrr Jun 12, 2026
306b5d9
feat(frontend): controlled suppression in useCostGuardrailModal (PR6a.1)
spencrr Jun 12, 2026
1795ade
feat(frontend): host-owned WorkspaceSettings wires cost-modal suppres…
spencrr Jun 12, 2026
7ef45cd
feat(frontend): dirty-edit guard on navigation away from the tree vie…
spencrr Jun 12, 2026
05d1e04
feat(frontend): open historical attack as a tree (PR7i.3b)
spencrr Jun 12, 2026
9c3e336
feat(frontend): fan-aware reload reconstruction for root-level conver…
spencrr Jun 12, 2026
65b2620
fix(frontend): address tree UI quality gate findings
spencrr Jun 13, 2026
7b817e3
feat(frontend): wire tree insert and fan affordances
spencrr Jun 13, 2026
d745764
feat(frontend): wire tree converter and pick controls
spencrr Jun 13, 2026
dcedb77
style(frontend): polish tree handles and action rail
spencrr Jun 13, 2026
b0c8bc5
feat(frontend): wire remaining tree node actions
spencrr Jun 13, 2026
2584269
fix(frontend): confirm tree node deletion
spencrr Jun 13, 2026
7077dde
test(frontend): add tree UI browser coverage
spencrr Jun 13, 2026
65229ec
style(frontend): theme tree canvas controls
spencrr Jun 13, 2026
55fc983
docs(gui): add tree UI V1 shipability plan
spencrr Jun 13, 2026
d33ddc4
feat(frontend): improve tree UI MVP
spencrr Jun 15, 2026
232f8a9
fixup(ruff): formatting
spencrr Jun 15, 2026
dceeafe
fixup(tests): fixup failing frontend/backend unit tests
spencrr Jun 15, 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
7 changes: 7 additions & 0 deletions .github/workflows/frontend_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,10 @@ jobs:

- name: Run TypeScript type check
run: npx tsc --noEmit

# Tree-UI contract tests rely on `satisfies` clauses that ts-jest does
# not enforce at runtime. tsconfig.contract.json scopes a type-check
# narrowly to the contract files + their type dependencies so backend
# wire-shape drift is caught at build time.
- name: Run tree-UI contract type check
run: npx tsc --noEmit -p tsconfig.contract.json
2,136 changes: 2,136 additions & 0 deletions doc/gui/design/01_tree_primitives.md

Large diffs are not rendered by default.

1,233 changes: 1,233 additions & 0 deletions doc/gui/design/02_tree_ui_affordances.md

Large diffs are not rendered by default.

1,273 changes: 1,273 additions & 0 deletions doc/gui/design/03_runner.md

Large diffs are not rendered by default.

523 changes: 523 additions & 0 deletions doc/gui/design/04_tree_ui_v1_shipability_plan.md

Large diffs are not rendered by default.

260 changes: 260 additions & 0 deletions frontend/e2e/tree-mvp.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import { test, expect, type Page, type TestInfo } from "@playwright/test";

interface MockAttackSummary {
attack_result_id: string;
conversation_id: string;
attack_type: string;
target?: { target_registry_name?: string | null; target_type: string; model_name?: string | null } | null;
converters: string[];
outcome?: "success" | "failure" | "undetermined" | null;
last_message_preview?: string | null;
message_count: number;
related_conversation_ids: string[];
labels: Record<string, string>;
created_at: string;
updated_at: string;
}

const ATTACK_ID = "atk-tree-mvp";
const MAIN_CONVERSATION_ID = "conv-main";
const BRANCH_CONVERSATION_ID = "conv-branch";
const TREE_ID = "tree-mvp";

const ATTACK: MockAttackSummary = {
attack_result_id: ATTACK_ID,
conversation_id: MAIN_CONVERSATION_ID,
attack_type: "ManualAttack",
target: { target_registry_name: "OpenAIChatTarget::mvp", target_type: "OpenAIChatTarget", model_name: "gpt-4o" },
converters: [],
outcome: "undetermined",
last_message_preview: "Answer A",
message_count: 8,
related_conversation_ids: [BRANCH_CONVERSATION_ID],
labels: { operator: "tree_mvp", operation: "tree_mvp", conversation_tree_id: TREE_ID },
created_at: "2026-06-12T00:00:00Z",
updated_at: "2026-06-12T00:01:00Z",
};

function piece(turn: number, role: string, value: string, pieceId: string) {
return {
turn_number: turn,
role,
pieces: [
{
piece_id: pieceId,
original_value_data_type: "text",
converted_value_data_type: "text",
original_value: value,
converted_value: value,
scores: [],
response_error: "none",
original_prompt_id: pieceId,
converter_identifiers: [],
},
],
created_at: "2026-06-12T00:00:00Z",
};
}

const MAIN_MESSAGES = {
conversation_id: MAIN_CONVERSATION_ID,
messages: [
piece(1, "user", "Root prompt", "main-p1"),
piece(2, "assistant", "Shared answer", "main-p2"),
piece(3, "user", "Follow A", "main-p3"),
piece(4, "assistant", "Answer A", "main-p4"),
],
};

const BRANCH_MESSAGES = {
conversation_id: BRANCH_CONVERSATION_ID,
messages: [
piece(1, "user", "Root prompt", "branch-p1"),
piece(2, "assistant", "Shared answer", "branch-p2"),
piece(3, "user", "Follow B", "branch-p3"),
piece(4, "assistant", "Answer B", "branch-p4"),
],
};

async function mockMvpApis(page: Page) {
await page.route(/\/api\/auth\/config/, async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ enabled: false }) });
});
await page.route(/\/api\/version/, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ version: "test", default_labels: { operator: "tree_mvp", operation: "tree_mvp" } }),
});
});
await page.route(/\/api\/health/, async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ status: "ok" }) });
});
await page.route(/\/api\/labels/, async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ source: "attacks", labels: {} }) });
});
await page.route(/\/api\/attacks\/attack-options/, async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ attack_types: ["ManualAttack"] }) });
});
await page.route(/\/api\/attacks\/converter-options/, async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ converter_types: [] }) });
});
await page.route(/\/api\/converters\/catalog(?:\?|$)/, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
converter_type: "Base64Converter",
supported_input_types: ["text"],
supported_output_types: ["text"],
is_llm_based: false,
description: "Encode text as base64.",
parameters: [
{
name: "encoding_func",
type_name: "Literal['b64encode', 'urlsafe_b64encode']",
required: false,
default_value: "b64encode",
choices: ["b64encode", "urlsafe_b64encode"],
description: "Encoding function",
},
],
},
],
}),
});
});
await page.route(/\/api\/converters(?:\?|$)/, async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ converter_id: "configured-base64", converter_type: "Base64Converter" }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [{ converter_id: "base64", converter_type: "Base64Converter", display_name: "Base64" }] }),
});
});
await page.route(new RegExp(`/api/attacks/${ATTACK_ID}$`), async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(ATTACK) });
});
await page.route(new RegExp(`/api/attacks/${ATTACK_ID}/conversations$`), async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
attack_result_id: ATTACK_ID,
main_conversation_id: MAIN_CONVERSATION_ID,
conversations: [
{ conversation_id: MAIN_CONVERSATION_ID, message_count: 4, last_message_preview: "Answer A" },
{ conversation_id: BRANCH_CONVERSATION_ID, message_count: 4, last_message_preview: "Answer B" },
],
}),
});
});
await page.route(new RegExp(`/api/attacks/${ATTACK_ID}/messages`), async (route) => {
const url = new URL(route.request().url());
const conversationId = url.searchParams.get("conversation_id");
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(conversationId === BRANCH_CONVERSATION_ID ? BRANCH_MESSAGES : MAIN_MESSAGES),
});
});
await page.route(/\/api\/attacks(?:\?|$)/, async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [ATTACK], pagination: { limit: 25, has_more: false, next_cursor: null, prev_cursor: null } }),
});
});
}

async function screenshot(page: Page, testInfo: TestInfo, name: string) {
await page.screenshot({ path: testInfo.outputPath(`${name}.png`), fullPage: true });
}

test.describe("Tree UI MVP acceptance", () => {
test.beforeEach(async ({ page }) => {
await mockMvpApis(page);
});

test("opens the loaded Chat attack as a merged tree with path chat", async ({ page }, testInfo) => {
await page.goto("/");
await page.getByTitle("Attack History").click();
await expect(page.getByTestId(`attack-row-${ATTACK_ID}`)).toBeVisible({ timeout: 10_000 });
await page.getByTestId(`open-attack-${ATTACK_ID}`).click();
await expect(page.getByText("Shared answer")).toBeVisible({ timeout: 10_000 });

await page.getByTestId("open-chat-attack-as-tree-btn").click();
await expect(page.locator("[data-tree-path-chat-pane]")).toBeVisible({ timeout: 10_000 });
await expect(page.locator("main")).toContainText("Root prompt");
await expect(page.getByText("Follow A")).toBeVisible();
await expect(page.getByText("Follow B")).toBeVisible();
await expect(page.locator("[data-tree-path-chat-splitter]")).toBeVisible();
await expect(page.locator("main")).not.toContainText(/\bSend\b|coming later|future release/i);
await screenshot(page, testInfo, "chat-open-merged-tree");
});

test("adding a follow-up prompt creates a pending response", async ({ page }, testInfo) => {
await page.goto("/");
await page.getByTitle("Attack History").click();
await page.getByTestId(`open-attack-as-tree-${ATTACK_ID}`).click();
await expect(page.getByText("Shared answer")).toBeVisible({ timeout: 10_000 });

await page.locator("[data-tree-node-id]").filter({ hasText: "Shared answer" }).locator('button[aria-label="Focus in path chat"]').first().click();
await page.getByRole("textbox", { name: "Follow-up prompt" }).fill("New prompt from path chat");
await page.getByRole("button", { name: "Run" }).click();
await expect(page.locator("[data-tree-path-chat-pane]")).toContainText("New prompt from path chat");
await expect(page.locator("[data-tree-path-chat-pane]")).toContainText("Pending response");
await screenshot(page, testInfo, "pending-response-follow-up");
});

test("attempt fan can be pruned to the picked path", async ({ page }, testInfo) => {
await page.goto("/");
await page.getByTitle("Attack History").click();
await page.getByTestId(`open-attack-as-tree-${ATTACK_ID}`).click();
await expect(page.getByText("Shared answer")).toBeVisible({ timeout: 10_000 });

await page.locator('button[aria-label="Fan out response attempts"]').first().click();
await page.getByRole("spinbutton", { name: "Attempt count" }).fill("3");
await page.getByRole("button", { name: "Create" }).click();
await expect(page.locator("main")).toContainText(/3 variants/);
await page.locator('button[aria-label="Pick this attempt"]').first().click();
await page.locator('button[aria-label^="Prune to picked slot"]').click();
await page.getByRole("button", { name: /^Prune$/ }).click();
await expect(page.locator("main")).not.toContainText(/3 variants/);
await screenshot(page, testInfo, "pruned-fan");
});

test("converter insertion creates a visible transform branch with direct baseline", async ({ page }, testInfo) => {
await page.goto("/");
await page.getByTitle("Attack History").click();
await page.getByTestId(`open-attack-as-tree-${ATTACK_ID}`).click();
await expect(page.getByText("Follow A")).toBeVisible({ timeout: 10_000 });

await page.getByRole("button", { name: "Insert after user turn" }).first().click();
await page.getByRole("menuitem", { name: "Append converter" }).click();
await expect(page.locator("[data-tree-node-id]").filter({ hasText: "Choose converter" }).first()).toBeVisible();
await expect(page.getByText("Answer A")).toBeVisible();
await expect(page.getByText("Pending response")).toBeVisible();

await page.getByRole("button", { name: "Choose converter" }).click();
await page.getByRole("menuitem", { name: "Configure converter..." }).click();
await page.getByRole("combobox", { name: "Converter type" }).selectOption("Base64Converter");
await page.getByTestId("param-encoding_func").selectOption("urlsafe_b64encode");
await page.getByRole("button", { name: "Save" }).click();
await expect(page.locator("[data-tree-node-id]").filter({ hasText: "Base64Converter" }).first()).toBeVisible();
await screenshot(page, testInfo, "converter-transform-branch");
});
});
Loading