Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
66 changes: 66 additions & 0 deletions apps/web/app/vnext/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,72 @@ describe("VNextPage", () => {
expect(screen.getByText("Follow up with Sam about launch owner")).toBeInTheDocument();
}, 60000); // heavy multi-action page test; allow bounded CI contention headroom

it("repopulates source, memory, and connector drafts when their selections change", async () => {
await renderVNextPage();

fireEvent.change(screen.getByLabelText("Selected source title"), {
target: { value: "Unsaved source draft" },
});
fireEvent.click(screen.getByRole("button", { name: /Vendor legal note/ }));
expect(screen.getByLabelText("Selected source title")).toHaveValue("Vendor legal note");
expect(screen.getByLabelText("Source domain")).toHaveValue("legal");
expect(screen.getByLabelText("Source sensitivity")).toHaveValue("internal");

fireEvent.click(screen.getByRole("button", { name: /Launch review note/ }));
expect(screen.getByLabelText("Selected source title")).toHaveValue("Launch review note");
expect(screen.getByLabelText("Source domain")).toHaveValue("project");
expect(screen.getByLabelText("Source sensitivity")).toHaveValue("private");

fireEvent.change(screen.getByLabelText("Edited memory title"), {
target: { value: "Unsaved memory draft" },
});
fireEvent.change(screen.getByLabelText("Open-loop title"), {
target: { value: "Unsaved open-loop draft" },
});
fireEvent.click(
screen.getByRole("button", { name: /Vendor legal review is waiting for Priya/ }),
);
expect(screen.getByLabelText("Edited memory title")).toHaveValue(
"Vendor legal review is waiting for Priya.",
);
expect(screen.getByLabelText("Edited memory text")).toHaveValue(
"Vendor legal review is waiting for Priya.",
);
expect(screen.getByLabelText("Domain label")).toHaveValue("legal");
expect(screen.getByLabelText("Sensitivity label")).toHaveValue("internal");
expect(screen.getByLabelText("Assigned project")).toHaveValue("project-fixture-1");
expect(screen.getByLabelText("Open-loop title")).toHaveValue(
"Vendor legal review is waiting for Priya.",
);

fireEvent.change(screen.getByLabelText("Connector"), {
target: { value: "browser_clipper" },
});
expect(screen.getByLabelText("Enabled")).toHaveValue("enabled");
expect(
screen.getByLabelText("Default domain", { selector: "#vnext-connector-domain" }),
).toHaveValue("professional");
fireEvent.change(
screen.getByLabelText("Default domain", { selector: "#vnext-connector-domain" }),
{ target: { value: "legal" } },
);
fireEvent.change(screen.getByLabelText("Connector"), {
target: { value: "local_folder" },
});
expect(
screen.getByLabelText("Default domain", { selector: "#vnext-connector-domain" }),
).toHaveValue("project");
fireEvent.change(screen.getByLabelText("Connector"), {
target: { value: "browser_clipper" },
});
expect(
screen.getByLabelText("Default domain", { selector: "#vnext-connector-domain" }),
).toHaveValue("professional");
expect(screen.getByLabelText("Trusted-client capture secret ref")).toHaveValue(
"browser.capture_token.default",
);
}, 30000);

it("refreshes Ask Alice output and generates reviewable artifacts", async () => {
await renderVNextPage();

Expand Down
68 changes: 68 additions & 0 deletions apps/web/components/approval-detail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,4 +251,72 @@ describe("ApprovalDetail", () => {
expect(screen.getByRole("button", { name: "Reject" })).toBeDisabled();
expect(screen.getByText("Approve and reject controls are disabled in fixture mode.")).toBeInTheDocument();
});

it("adopts refreshed detail objects without over-resetting write-back form state", () => {
const { rerender } = render(
<ApprovalDetail
initialApproval={approval}
detailSource="live"
initialExecution={execution}
executionSource="live"
apiBaseUrl="https://api.example.com"
userId="user-1"
/>,
);

fireEvent.change(screen.getByLabelText("Memory key"), {
target: { value: "user.preference.supplement.magnesium" },
});
fireEvent.change(screen.getByLabelText("Memory value (JSON)"), {
target: { value: "not-json" },
});
fireEvent.click(screen.getByRole("button", { name: "Submit memory write-back" }));
expect(screen.getByText("Memory value must be valid JSON.")).toBeInTheDocument();

const refreshedApproval = {
...approval,
tool: { ...approval.tool, name: "Updated Merchant Proxy" },
};
const refreshedExecution = {
...execution,
id: "execution-2",
trace_id: "trace-execution-2",
request_event_id: "event-request-2",
result_event_id: "event-result-2",
};
rerender(
<ApprovalDetail
initialApproval={refreshedApproval}
detailSource="live"
initialExecution={refreshedExecution}
executionSource="live"
apiBaseUrl="https://api.example.com"
userId="user-1"
/>,
);

expect(screen.getByRole("heading", { name: "Updated Merchant Proxy" })).toBeInTheDocument();
expect(screen.getAllByText("event-result-2").length).toBeGreaterThan(0);
expect(screen.getAllByText("event-request-2").length).toBeGreaterThan(0);
expect(screen.getByText("Memory value must be valid JSON.")).toBeInTheDocument();
expect(screen.getByLabelText("Memory key")).toHaveValue("user.preference.supplement.magnesium");
expect(screen.getByLabelText("Memory value (JSON)")).toHaveValue("not-json");

rerender(
<ApprovalDetail
initialApproval={refreshedApproval}
detailSource="live"
initialExecution={null}
executionSource="live"
apiBaseUrl="https://api.example.com"
userId="user-1"
/>,
);

expect(
screen.getByText("Execution evidence is required before memory write-back can be submitted."),
).toBeInTheDocument();
expect(screen.getByLabelText("Memory key")).toHaveValue("user.preference.supplement.magnesium");
expect(screen.getByLabelText("Memory value (JSON)")).toHaveValue("not-json");
});
});
16 changes: 12 additions & 4 deletions apps/web/components/approval-detail.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useState } from "react";
import { useState } from "react";

import type { ApprovalExecutionResponse, ApprovalItem, ApiSource, ToolExecutionItem } from "../lib/api";
import { EmptyState } from "./empty-state";
Expand Down Expand Up @@ -55,12 +55,20 @@ export function ApprovalDetail({
const [approval, setApproval] = useState(initialApproval);
const [execution, setExecution] = useState(initialExecution);
const [executionPreview, setExecutionPreview] = useState<ApprovalExecutionResponse | null>(null);

useEffect(() => {
const [previousInitialState, setPreviousInitialState] = useState(() => ({
approval: initialApproval,
execution: initialExecution,
}));

if (
initialApproval !== previousInitialState.approval ||
initialExecution !== previousInitialState.execution
) {
setPreviousInitialState({ approval: initialApproval, execution: initialExecution });
setApproval(initialApproval);
setExecution(initialExecution);
setExecutionPreview(null);
}, [initialApproval, initialExecution]);
}

if (!approval) {
return (
Expand Down
78 changes: 78 additions & 0 deletions apps/web/components/calendar-event-ingest-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,82 @@ describe("CalendarEventIngestForm", () => {
expect(screen.getByText("Select one discovered event before submitting ingestion.")).toBeInTheDocument();
expect(ingestCalendarEventMock).not.toHaveBeenCalled();
});

it("preserves valid workspace state and resets only prop-derived status when inputs change", async () => {
const secondWorkspace = {
...baseWorkspaces[0],
id: "workspace-2",
task_id: "task-2",
local_path: "/tmp/task-workspaces/task-2",
};
const replacementWorkspace = {
...baseWorkspaces[0],
id: "workspace-3",
task_id: "task-3",
local_path: "/tmp/task-workspaces/task-3",
};
ingestCalendarEventMock.mockResolvedValue({
account: baseAccount,
event: {
provider_event_id: "evt-001",
artifact_relative_path: "calendar/acct-owner-001/evt-001.txt",
media_type: "text/plain",
},
artifact: {
id: "artifact-1",
task_id: "task-1",
task_workspace_id: "workspace-2",
status: "registered",
ingestion_status: "ingested",
relative_path: "calendar/acct-owner-001/evt-001.txt",
media_type_hint: "text/plain",
created_at: "2026-03-18T10:10:00Z",
updated_at: "2026-03-18T10:11:00Z",
},
summary: {
total_count: 1,
total_characters: 256,
media_type: "text/plain",
chunking_rule: "normalized_utf8_text_fixed_window_1000_chars_v1",
order: ["sequence_no_asc", "id_asc"],
},
});

const renderForm = (account = baseAccount, taskWorkspaces = [baseWorkspaces[0], secondWorkspace]) => (
<CalendarEventIngestForm
account={account}
accountSource="live"
selectedProviderEventId="evt-001"
selectedEventSource="live"
taskWorkspaces={taskWorkspaces}
taskWorkspaceSource="live"
apiBaseUrl="https://api.example.com"
userId="user-1"
/>
);
const { rerender } = render(renderForm());

fireEvent.change(screen.getByLabelText("Task workspace"), {
target: { value: "workspace-2" },
});
fireEvent.click(screen.getByRole("button", { name: "Ingest selected event" }));
await screen.findByText(/Ingestion completed\./i);

rerender(renderForm({ ...baseAccount }, [{ ...baseWorkspaces[0] }, { ...secondWorkspace }]));
expect(screen.getByLabelText("Task workspace")).toHaveValue("workspace-2");
expect(screen.getByText("Select one task workspace to ingest the discovered event.")).toBeInTheDocument();
expect(screen.getAllByText("calendar/acct-owner-001/evt-001.txt").length).toBeGreaterThan(0);

rerender(renderForm({ ...baseAccount }, [replacementWorkspace]));
expect(screen.getByLabelText("Task workspace")).toHaveValue("workspace-3");
fireEvent.click(screen.getByRole("button", { name: "Ingest selected event" }));
await waitFor(() => {
expect(ingestCalendarEventMock).toHaveBeenLastCalledWith(
"https://api.example.com",
"calendar-account-1",
"evt-001",
{ user_id: "user-1", task_workspace_id: "workspace-3" },
);
});
});
});
109 changes: 63 additions & 46 deletions apps/web/components/calendar-event-ingest-form.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import type { FormEvent } from "react";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";

import { useRouter } from "next/navigation";

Expand All @@ -28,6 +28,27 @@ type CalendarEventIngestFormProps = {
userId?: string;
};

function defaultStatusText(options: {
hasAccount: boolean;
hasTaskWorkspace: boolean;
hasSelectedEvent: boolean;
liveModeReady: boolean;
}) {
if (!options.hasAccount) {
return "Select a Calendar account to enable single-event ingestion.";
}
if (!options.hasTaskWorkspace) {
return "No task workspace is available for ingestion target selection.";
}
if (!options.hasSelectedEvent) {
return "Select one discovered event before submitting ingestion.";
}
if (options.liveModeReady) {
return "Select one task workspace to ingest the discovered event.";
}
return "Event ingestion is unavailable until live API configuration, live account detail, and live task workspace list are present.";
}

export function CalendarEventIngestForm({
account,
accountSource,
Expand Down Expand Up @@ -61,55 +82,51 @@ export function CalendarEventIngestForm({
[account, accountSource, apiBaseUrl, userId, taskWorkspaceSource, taskWorkspaces.length],
);

const [statusText, setStatusText] = useState(
!account
? "Select a Calendar account to enable single-event ingestion."
: taskWorkspaces.length === 0
? "No task workspace is available for ingestion target selection."
: !hasSelectedEvent
? "Select one discovered event before submitting ingestion."
: liveModeReady
? "Select one task workspace to ingest the discovered event."
: "Event ingestion is unavailable until live API configuration, live account detail, and live task workspace list are present.",
const [statusText, setStatusText] = useState(() =>
defaultStatusText({
hasAccount: Boolean(account),
hasTaskWorkspace: taskWorkspaces.length > 0,
hasSelectedEvent,
liveModeReady,
}),
);
const [previousStatusInputs, setPreviousStatusInputs] = useState(() => ({
account,
hasSelectedEvent,
liveModeReady,
taskWorkspaceCount: taskWorkspaces.length,
}));

useEffect(() => {
const hasWorkspace = taskWorkspaces.some((workspace) => workspace.id === taskWorkspaceId);
if (!hasWorkspace) {
setTaskWorkspaceId(taskWorkspaces[0]?.id ?? "");
}
}, [taskWorkspaceId, taskWorkspaces]);

useEffect(() => {
if (!account) {
setStatusTone("info");
setStatusText("Select a Calendar account to enable single-event ingestion.");
return;
}

if (taskWorkspaces.length === 0) {
setStatusTone("info");
setStatusText("No task workspace is available for ingestion target selection.");
return;
}

if (!hasSelectedEvent) {
setStatusTone("info");
setStatusText("Select one discovered event before submitting ingestion.");
return;
}

if (!liveModeReady) {
setStatusTone("info");
setStatusText(
"Event ingestion is unavailable until live API configuration, live account detail, and live task workspace list are present.",
);
return;
}
const fallbackTaskWorkspaceId = taskWorkspaces[0]?.id ?? "";
if (
!taskWorkspaces.some((workspace) => workspace.id === taskWorkspaceId) &&
taskWorkspaceId !== fallbackTaskWorkspaceId
) {
setTaskWorkspaceId(fallbackTaskWorkspaceId);
}

if (
account !== previousStatusInputs.account ||
hasSelectedEvent !== previousStatusInputs.hasSelectedEvent ||
liveModeReady !== previousStatusInputs.liveModeReady ||
taskWorkspaces.length !== previousStatusInputs.taskWorkspaceCount
) {
setPreviousStatusInputs({
account,
hasSelectedEvent,
liveModeReady,
taskWorkspaceCount: taskWorkspaces.length,
});
setStatusTone("info");
setStatusText("Select one task workspace to ingest the discovered event.");
}, [account, hasSelectedEvent, liveModeReady, taskWorkspaces.length]);
setStatusText(
defaultStatusText({
hasAccount: Boolean(account),
hasTaskWorkspace: taskWorkspaces.length > 0,
hasSelectedEvent,
liveModeReady,
}),
);
}

async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
Expand Down
Loading
Loading