Skip to content
Open
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
196 changes: 196 additions & 0 deletions tests/web/app-render.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ function renderWithI18n(node: ReturnType<typeof createElement>) {
return render(createElement(I18nextProvider, { i18n }, node));
}

function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((next) => {
resolve = next;
});
return { promise, resolve };
}

describe("OpenPI React transcript", () => {
it("renders sanitized GFM and projects images as links", () => {
const { container } = render(
Expand Down Expand Up @@ -784,3 +792,191 @@ it("does not repeat a provider identity used as the fallback model label", () =>
screen.queryByText("provider-alpha/model-a (provider-alpha/model-a)"),
).toBeNull();
});

it("keeps a retyped draft when an earlier send settles", async () => {
const result = deferred<boolean>();
const store = createWebStore();
const sendPrompt = vi.fn(() => result.promise);
const snapshot = activeSnapshot();
snapshot.runtime.status = "idle";
const props = {
snapshot,
selectedPath: "/tmp/session",
selectedWorkspace: "/tmp",
sessionSwitching: false,
promptAdmissionPending: false,
liveRunning: false,
landing: false,
activeTurn: null,
turnCancellationPending: false,
turnTerminalStatus: null,
pendingFollowUpsReceipt: null,
actions: { ...store.getState().actions, sendPrompt },
};
renderWithI18n(createElement(Composer, props));
const input = screen.getByRole<HTMLTextAreaElement>("textbox");

fireEvent.change(input, { target: { value: "first" } });
fireEvent.click(screen.getByRole("button", { name: i18n.t("send") }));
fireEvent.change(input, { target: { value: "second" } });
fireEvent.change(input, { target: { value: "first" } });

await act(async () => {
result.resolve(true);
await result.promise;
});

expect(input.value).toBe("first");
});

it("keeps a draft after a failed send", async () => {
const result = deferred<boolean>();
const store = createWebStore();
const sendPrompt = vi.fn(() => result.promise);
const snapshot = activeSnapshot();
snapshot.runtime.status = "idle";
renderWithI18n(
createElement(Composer, {
snapshot,
selectedPath: "/tmp/session",
selectedWorkspace: "/tmp",
sessionSwitching: false,
promptAdmissionPending: false,
liveRunning: false,
landing: false,
activeTurn: null,
turnCancellationPending: false,
turnTerminalStatus: null,
pendingFollowUpsReceipt: null,
actions: { ...store.getState().actions, sendPrompt },
}),
);
const input = screen.getByRole<HTMLTextAreaElement>("textbox");

fireEvent.change(input, { target: { value: "keep me" } });
fireEvent.click(screen.getByRole("button", { name: i18n.t("send") }));
await act(async () => {
result.resolve(false);
await result.promise;
});

expect(input.value).toBe("keep me");
});

it("clears an old session draft without letting its late send clear the new one", async () => {
const result = deferred<boolean>();
const store = createWebStore();
const sendPrompt = vi.fn(() => result.promise);
const snapshot = activeSnapshot();
snapshot.runtime.status = "idle";
const props = {
snapshot,
selectedPath: "/tmp/session",
selectedWorkspace: "/tmp",
sessionSwitching: false,
promptAdmissionPending: false,
liveRunning: false,
landing: false,
activeTurn: null,
turnCancellationPending: false,
turnTerminalStatus: null,
pendingFollowUpsReceipt: null,
actions: { ...store.getState().actions, sendPrompt },
};
const view = renderWithI18n(createElement(Composer, props));
const input = screen.getByRole<HTMLTextAreaElement>("textbox");
fireEvent.change(input, { target: { value: "old session" } });
fireEvent.click(screen.getByRole("button", { name: i18n.t("send") }));

const nextSnapshot = {
...snapshot,
currentSessionId: "next-session",
selectedSession: {
...snapshot.selectedSession!,
id: "next-session",
path: "/tmp/next-session",
},
};
view.rerender(
createElement(
I18nextProvider,
{ i18n },
createElement(Composer, {
...props,
snapshot: nextSnapshot,
selectedPath: "/tmp/next-session",
}),
),
);
expect(input.value).toBe("");

fireEvent.change(input, { target: { value: "new session" } });
await act(async () => {
result.resolve(true);
await result.promise;
});

expect(input.value).toBe("new session");
});

it("transfers a new-session draft until its first send is accepted", async () => {
const result = deferred<boolean>();
const store = createWebStore();
const sendPrompt = vi.fn(() => result.promise);
const draftSnapshot = activeSnapshot();
draftSnapshot.runtime.status = "idle";
delete draftSnapshot.currentSessionId;
delete draftSnapshot.selectedSession;
draftSnapshot.sessions = [];
const props = {
snapshot: draftSnapshot,
selectedPath: null,
selectedWorkspace: "/tmp",
sessionSwitching: false,
promptAdmissionPending: false,
liveRunning: false,
landing: true,
activeTurn: null,
turnCancellationPending: false,
turnTerminalStatus: null,
pendingFollowUpsReceipt: null,
actions: { ...store.getState().actions, sendPrompt },
};
const view = renderWithI18n(createElement(Composer, props));
const input = screen.getByRole<HTMLTextAreaElement>("textbox");
fireEvent.change(input, { target: { value: "first prompt" } });
fireEvent.click(screen.getByRole("button", { name: i18n.t("send") }));

const createdSnapshot = {
...draftSnapshot,
currentSessionId: "created-session",
selectedSession: {
id: "created-session",
path: "/tmp/created-session",
cwd: "/tmp",
entries: [],
bytes: 0,
truncation,
},
};
view.rerender(
createElement(
I18nextProvider,
{ i18n },
createElement(Composer, {
...props,
snapshot: createdSnapshot,
selectedPath: "/tmp/created-session",
sessionSwitching: true,
landing: false,
}),
),
);
expect(input.value).toBe("first prompt");

await act(async () => {
result.resolve(true);
await result.promise;
});
expect(input.value).toBe("");
});
4 changes: 2 additions & 2 deletions web/dist/app.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions web/ui/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ export function App() {
turnTerminalStatus={state.turnTerminalStatus}
pendingFollowUpsReceipt={state.pendingFollowUpsReceipt}
snapshot={state.snapshot}
selectedPath={state.selectedPath}
selectedWorkspace={state.selectedWorkspace}
sessionSwitching={state.sessionSwitching}
promptAdmissionPending={state.promptAdmissionPending}
Expand Down
70 changes: 64 additions & 6 deletions web/ui/src/features/composer/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
Square,
SlidersHorizontal,
} from "lucide-react";
import { type FormEvent, useRef, useState } from "react";
import { type FormEvent, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type {
WebModelSummary,
Expand All @@ -25,6 +25,7 @@ interface ComposerProps {
modelSelectionPending?: boolean;
onInspect?: (terminalId?: string) => void;
snapshot: WebSnapshot | null;
selectedPath?: string | null;
selectedWorkspace: string | null;
sessionSwitching: boolean;
promptAdmissionPending: boolean;
Expand All @@ -47,6 +48,20 @@ export function Composer(props: ComposerProps) {
const [prompt, setPrompt] = useState("");
const textarea = useRef<HTMLTextAreaElement>(null);
const selected = props.snapshot?.selectedSession;
const selectedPath =
props.selectedPath === undefined
? (selected?.path ?? null)
: props.selectedPath;
const draftScope =
selectedPath ??
(props.selectedWorkspace ? `new:${props.selectedWorkspace}` : "none");
const draftScopeRef = useRef(draftScope);
const draftRevision = useRef(0);
const pendingSubmission = useRef<{
revision: number;
scope: string;
canTransferToCreatedSession: boolean;
} | null>(null);
const active = Boolean(
!props.workspaceDraft &&
selected?.id &&
Expand All @@ -68,6 +83,29 @@ export function Composer(props: ComposerProps) {
const disabled =
props.sessionSwitching || (!canCompose && Boolean(props.selectedWorkspace));

useEffect(() => {
const previousScope = draftScopeRef.current;
if (previousScope === draftScope) return;
draftScopeRef.current = draftScope;

const submission = pendingSubmission.current;
const createdSession =
submission?.canTransferToCreatedSession &&
submission.scope === previousScope &&
Boolean(selectedPath) &&
props.snapshot?.selectedSession?.path === selectedPath;
if (createdSession) {
submission.scope = draftScope;
return;
}

draftRevision.current += 1;
setPrompt("");
if (submission?.scope === previousScope) {
submission.canTransferToCreatedSession = false;
}
}, [draftScope, props.snapshot?.selectedSession?.path, selectedPath]);

const resize = (element: HTMLTextAreaElement) => {
element.style.height = "auto";
element.style.height = `${Math.min(element.scrollHeight, 220)}px`;
Expand All @@ -80,11 +118,30 @@ export function Composer(props: ComposerProps) {
await props.actions.chooseWorkspace();
return;
}
if (await props.actions.sendPrompt(prompt)) {
setPrompt("");
if (textarea.current) {
textarea.current.style.height = "auto";
textarea.current.style.overflowY = "hidden";
const submission = {
revision: draftRevision.current,
scope: draftScopeRef.current,
canTransferToCreatedSession: draftSession,
};
pendingSubmission.current = submission;
try {
if (await props.actions.sendPrompt(prompt)) {
if (
pendingSubmission.current === submission &&
draftScopeRef.current === submission.scope &&
draftRevision.current === submission.revision
) {
draftRevision.current += 1;
setPrompt("");
if (textarea.current) {
textarea.current.style.height = "auto";
textarea.current.style.overflowY = "hidden";
}
}
}
} finally {
if (pendingSubmission.current === submission) {
pendingSubmission.current = null;
}
}
};
Expand Down Expand Up @@ -204,6 +261,7 @@ export function Composer(props: ComposerProps) {
aria-label={t("describeTask")}
placeholder={placeholder}
onChange={(event) => {
draftRevision.current += 1;
setPrompt(event.target.value);
resize(event.currentTarget);
}}
Expand Down
Loading