Skip to content
Closed
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
3 changes: 3 additions & 0 deletions dashboard/src/api/modules/octopThreads.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { HitlPendingPayload } from "../types/hitl";
import type { UserQuestionPendingPayload } from "../types/userQuestions";
import { request } from "../request";

export interface OctopThread {
Expand Down Expand Up @@ -37,6 +38,8 @@ export interface OctopThreadHistory {
turn_active?: boolean;
/** Pending tool approval for this thread (survives page reload). */
hitl_pending?: HitlPendingPayload | null;
/** Pending structured question for this thread (durable across server restart). */
question_pending?: UserQuestionPendingPayload | null;
}

export interface OctopThreadPatch {
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/api/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./hitl";
export * from "./userQuestions";
export * from "./agent";
export * from "./channel";
export * from "./chat";
Expand Down
23 changes: 23 additions & 0 deletions dashboard/src/api/types/userQuestions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export interface UserQuestionOption {
label: string;
description?: string;
}

export interface UserQuestion {
id: string;
question: string;
header?: string;
options: UserQuestionOption[];
multi_select?: boolean;
}

export interface UserQuestionAnswer {
id: string;
selected: string[];
custom?: string;
}

export interface UserQuestionPendingPayload {
pending_id: string;
questions: UserQuestion[];
}
9 changes: 9 additions & 0 deletions dashboard/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1043,6 +1043,14 @@
"rejectedLabel": "Rejected",
"rejected": "Rejected by user"
},
"questions": {
"recommended": "Recommended",
"customPlaceholder": "Type another answer…",
"skip": "Skip",
"next": "Next",
"submit": "Submit",
"skipped": "Skipped"
},
"modifiedFiles": "Modified files ({{count}})",
"openBrowser": "View browser",
"openBrowserHint": "Agent is browsing the web",
Expand All @@ -1069,6 +1077,7 @@
"bash": "Shell (bash)",
"current_time": "Current time",
"write_todos": "Write plan",
"ask_user_question": "Ask user",
"task": "Sub-agent task",
"ls": "List directory",
"glob": "Find files",
Expand Down
9 changes: 9 additions & 0 deletions dashboard/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -1042,6 +1042,14 @@
"rejectedLabel": "已拒绝",
"rejected": "用户已拒绝"
},
"questions": {
"recommended": "推荐",
"customPlaceholder": "输入其他答案…",
"skip": "跳过",
"next": "继续",
"submit": "提交",
"skipped": "已跳过"
},
"modifiedFiles": "已修改文件({{count}})",
"openBrowser": "查看浏览器",
"openBrowserHint": "Agent 正在网页中操作",
Expand All @@ -1068,6 +1076,7 @@
"bash": "Shell 命令 (bash)",
"current_time": "当前时间",
"write_todos": "编写计划",
"ask_user_question": "询问用户",
"task": "子智能体任务",
"ls": "列出目录",
"glob": "查找文件",
Expand Down
62 changes: 62 additions & 0 deletions dashboard/src/pages/Chat/components/AskUserQuestionCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

import AskUserQuestionCard from "./AskUserQuestionCard";

describe("AskUserQuestionCard", () => {
it("keeps the final single choice explicit until Submit", () => {
const onSubmit = vi.fn();
render(
<AskUserQuestionCard
data={{
pendingId: "pending-1",
status: "pending",
questions: [
{
id: "database",
question: "Which database?",
options: [{ label: "SQLite" }, { label: "PostgreSQL" }],
},
],
}}
onSubmit={onSubmit}
/>,
);

fireEvent.click(screen.getByRole("button", { name: /SQLite/ }));
expect(onSubmit).not.toHaveBeenCalled();
fireEvent.click(
screen.getByRole("button", { name: "chat.questions.submit" }),
);
expect(onSubmit).toHaveBeenCalledWith("pending-1", [
{ id: "database", selected: ["SQLite"] },
]);
});

it("advances after a non-final single choice", () => {
render(
<AskUserQuestionCard
data={{
pendingId: "pending-2",
status: "pending",
questions: [
{
id: "one",
question: "First question?",
options: [{ label: "A" }, { label: "B" }],
},
{
id: "two",
question: "Second question?",
options: [],
},
],
}}
/>,
);

fireEvent.click(screen.getByRole("button", { name: /A/ }));
expect(screen.getByText("Second question?")).toBeInTheDocument();
expect(screen.getByText("2 / 2")).toBeInTheDocument();
});
});
234 changes: 234 additions & 0 deletions dashboard/src/pages/Chat/components/AskUserQuestionCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import { useMemo, useState } from "react";
import { Button, Input } from "antd";
import { Check, ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";

import type { UserQuestionAnswer } from "../../../api/types/userQuestions";
import type { UserQuestionRequestData } from "../hooks/sseHelpers";
import styles from "../index.module.less";

interface DraftAnswer {
selected: string[];
custom: string;
skipped: boolean;
}

interface AskUserQuestionCardProps {
data: UserQuestionRequestData;
onSubmit?: (pendingId: string, answers: UserQuestionAnswer[]) => void;
}

function displayOption(label: string): {
label: string;
recommended: boolean;
} {
const suffix =
/\s*(?:\((?:recommended|推荐)\)|((?:recommended|推荐)))\s*$/i;
return {
label: label.replace(suffix, ""),
recommended: suffix.test(label),
};
}

export default function AskUserQuestionCard({
data,
onSubmit,
}: AskUserQuestionCardProps) {
const { t } = useTranslation();
const [index, setIndex] = useState(0);
const [drafts, setDrafts] = useState<DraftAnswer[]>(() =>
data.questions.map(() => ({ selected: [], custom: "", skipped: false })),
);
const question = data.questions[index];
const draft = drafts[index];
const pending = data.status === "pending";

const updateDraft = (next: DraftAnswer) => {
setDrafts((current) =>
current.map((item, itemIndex) => (itemIndex === index ? next : item)),
);
};

const choose = (label: string) => {
if (!question || !draft || !pending) return;
if (question.multi_select) {
const selected = draft.selected.includes(label)
? draft.selected.filter((item) => item !== label)
: [...draft.selected, label];
updateDraft({ ...draft, selected, skipped: false });
return;
}
updateDraft({ selected: [label], custom: "", skipped: false });
if (index < data.questions.length - 1) setIndex(index + 1);
};

const answered = (value: DraftAnswer) =>
value.skipped || value.selected.length > 0 || Boolean(value.custom.trim());
const allAnswered = drafts.every(answered);

const submit = (values = drafts) => {
if (!onSubmit || !allAnswered) return;
onSubmit(
data.pendingId,
data.questions.map((item, itemIndex) => {
const value = values[itemIndex];
const custom = value.custom.trim();
return {
id: item.id,
selected: value.skipped
? []
: custom && !item.multi_select
? []
: value.selected,
...(custom ? { custom } : {}),
};
}),
);
};

const continueFlow = () => {
if (!draft || !answered(draft)) return;
if (index < data.questions.length - 1) setIndex(index + 1);
else submit();
};

const skip = () => {
const next = drafts.map((item, itemIndex) =>
itemIndex === index ? { selected: [], custom: "", skipped: true } : item,
);
setDrafts(next);
if (index < data.questions.length - 1) setIndex(index + 1);
else if (next.every(answered) && onSubmit) {
onSubmit(
data.pendingId,
data.questions.map((item, itemIndex) => ({
id: item.id,
selected: next[itemIndex].selected,
...(next[itemIndex].custom.trim()
? { custom: next[itemIndex].custom.trim() }
: {}),
})),
);
}
};

const resolvedSummary = useMemo(() => {
if (pending || !data.answers?.length) return "";
return data.answers
.map((answer) => answer.custom || answer.selected.join(", "))
.filter(Boolean)
.join(" · ");
}, [data.answers, pending]);

if (!question || !draft) return null;

return (
<section className={styles.askUserCard} aria-label={question.question}>
<header className={styles.askUserHeader}>
<div>
{question.header ? (
<div className={styles.askUserEyebrow}>{question.header}</div>
) : null}
<div className={styles.askUserTitle}>{question.question}</div>
</div>
<div className={styles.askUserProgress}>
{index + 1} / {data.questions.length}
</div>
</header>

{pending ? (
<div className={styles.askUserBody}>
{question.options.map((option, optionIndex) => {
const selected = draft.selected.includes(option.label);
const display = displayOption(option.label);
return (
<button
type="button"
key={option.label}
className={`${styles.askUserOption} ${
selected ? styles.askUserOptionSelected : ""
}`}
aria-pressed={selected}
onClick={() => choose(option.label)}
>
<span className={styles.askUserOptionMarker}>
{question.multi_select && selected ? (
<Check size={13} />
) : (
optionIndex + 1
)}
</span>
<span className={styles.askUserOptionCopy}>
<span>
{display.label}
{display.recommended ? (
<span className={styles.askUserRecommended}>
{t("chat.questions.recommended")}
</span>
) : null}
</span>
{option.description ? (
<small>{option.description}</small>
) : null}
</span>
</button>
);
})}
<Input.TextArea
className={styles.askUserCustom}
autoSize={{ minRows: question.options.length ? 1 : 2, maxRows: 5 }}
placeholder={t("chat.questions.customPlaceholder")}
value={draft.custom}
onChange={(event) =>
updateDraft({
selected: question.multi_select ? draft.selected : [],
custom: event.target.value,
skipped: false,
})
}
onPressEnter={(event) => {
if (event.shiftKey || event.nativeEvent.isComposing) return;
event.preventDefault();
continueFlow();
}}
/>
</div>
) : (
<div className={styles.askUserResolved}>
{resolvedSummary || t("chat.questions.skipped")}
</div>
)}

{pending ? (
<footer className={styles.askUserFooter}>
<div className={styles.askUserPager}>
<Button
type="text"
icon={<ChevronLeft size={15} />}
disabled={index === 0}
onClick={() => setIndex(index - 1)}
/>
<Button
type="text"
icon={<ChevronRight size={15} />}
disabled={index === data.questions.length - 1}
onClick={() => setIndex(index + 1)}
/>
</div>
<div className={styles.askUserActions}>
<Button onClick={skip}>{t("chat.questions.skip")}</Button>
<Button
type="primary"
disabled={!answered(draft)}
onClick={continueFlow}
>
{index === data.questions.length - 1
? t("chat.questions.submit")
: t("chat.questions.next")}
</Button>
</div>
</footer>
) : null}
</section>
);
}
Loading
Loading