From c87060ffed1e58824952fcdbd69e9942cc0df838 Mon Sep 17 00:00:00 2001 From: 8syncdev Date: Thu, 15 Oct 2026 15:38:02 +0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(hq-ui):=20redesign=20the=20Giao=20vi?= =?UTF-8?q?=E1=BB=87c=20dialog=20=E2=80=94=20one=20session,=20by=20role,?= =?UTF-8?q?=20or=20whole=20project?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner (2026-10-15): "khó dùng giao cho all session repo hay sao giao cho session nào agent role nào ui ux khó hiểu khó sài chưa đẹp". One dropdown mixed roles and sessions, so it was unclear whether a task went to one session, to a role, or to every session of a repo. - Three explicit modes: Một phiên (live sessions grouped by project with status, role, terminal/inbox), Theo vai trò (role cards with model + running count, project picker when a new session must start) and Cả dự án (every live session of one project or of all, one task row each, sent one after another). - A summary line says exactly where the task will go before the button is pressed. - P0–P3 segmented control, "Khi nào" beside it, R&D preset as a text link, hint when a role session exists only outside HQ. - Pure rules in lib/assign.ts (groupTargets, roleTargets, outsideRole, projectTargets, assignSummary) with tests/assign-ui.test.ts; RoleIcon resolved at module load (react-hooks/static-components). Checked: tsc, eslint, vitest (assign-ui 9, guide, roles); screenshots from a headless browser against a seeded preview at desktop and phone width in dark theme. Not verified: Windows/macOS browsers. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 + apps/hq-ui/components/hq/assign-dialog.tsx | 440 +++++++++++++++------ apps/hq-ui/components/hq/guide.tsx | 2 +- apps/hq-ui/components/hq/role-badge.tsx | 16 +- apps/hq-ui/lib/assign.ts | 82 ++++ apps/hq-ui/tests/assign-ui.test.ts | 93 +++++ 6 files changed, 501 insertions(+), 134 deletions(-) create mode 100644 apps/hq-ui/lib/assign.ts create mode 100644 apps/hq-ui/tests/assign-ui.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bf744456..77bbf046 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Mọi thay đổi đáng kể của `8sync` ghi vào đây. Format theo [Keep a versioning theo [SemVer](https://semver.org). **8sync rule:** mỗi PR cập nhật mục `Unreleased`. ## [Unreleased] +### Changed +- **HQ "Giao việc" dialog redesigned.** Owner (2026-10-15): one dropdown mixed roles and sessions, so it was unclear whether a task went to one session, to a role, or to every session of a repo. The dialog now has three explicit ways to hand a task: **Một phiên** (a list of live sessions grouped by project, with status dot, role and terminal/inbox delivery), **Theo vai trò** (role cards showing model and how many sessions of that role are running; "mở mới" + project picker when none) and **Cả dự án** (every live session of one project, or of all projects, each gets its own task row). A summary line above the buttons says exactly where the task will go, e.g. "Giao cho phiên rd qua inbox", "Giao cho phiên R&D rảnh lâu nhất trong 2 phiên: rd-1, rd-2" or "Giao cùng một việc cho 4 phiên trong levels-repo". Priority is a P0–P3 segmented control; "Khi nào" sits beside it; the R&D preset is a text link. Pure rules live in `lib/assign.ts` (tests/assign-ui.test.ts). Dark/light and phone widths checked with a headless browser against a seeded preview (not verified: Windows, macOS browsers). ## [0.91.1] — 2026-10-15 ### Fixed diff --git a/apps/hq-ui/components/hq/assign-dialog.tsx b/apps/hq-ui/components/hq/assign-dialog.tsx index 8e5f56b2..5615d4ad 100644 --- a/apps/hq-ui/components/hq/assign-dialog.tsx +++ b/apps/hq-ui/components/hq/assign-dialog.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, type FormEvent } from "react"; +import { ArrowRightIcon, CheckIcon, FolderIcon, InboxIcon, SparklesIcon, TerminalSquareIcon, UserIcon, UsersIcon } from "lucide-react"; +import { useState, type FormEvent, type ReactNode } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; @@ -8,22 +9,35 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { assignSummary, groupTargets, outsideRole, projectTargets, roleTargets, type AssignMode } from "@/lib/assign"; +import { addressOf, deliveryOf, sessionLabel } from "@/lib/labels"; import { ROLES, modelShort, type RoleId } from "@/lib/roles"; import { EVERY_HOURS, EVERY_MINUTES, RD_DUTY, scheduleLabel, type Schedule } from "@/lib/schedule"; -import { addressOf, messageTargets, sessionLabel } from "@/lib/labels"; +import { STATUS_LABEL, StatusDot } from "./bits"; +import { RoleIcon } from "./role-badge"; import type { Hq } from "./use-hq"; import type { Message, Project, Session, Task } from "./types"; -const PRIOS = ["P0", "P1", "P2", "P3"] as const; +const PRIOS = [ + ["P0", "Khẩn"], + ["P1", "Cao"], + ["P2", "Thường"], + ["P3", "Thấp"], +] as const; /** "Khi nào": now, once at HH:MM, daily at HH:MM, or every N minutes/hours (`m:30`, `h:2`). */ const WHEN_EVERY = [...EVERY_MINUTES.map((n) => `m:${n}`), ...EVERY_HOURS.map((n) => `h:${n}`)]; +const MODES: { id: AssignMode; label: string; icon: typeof UserIcon; hint: string }[] = [ + { id: "session", label: "Một phiên", icon: UserIcon, hint: "Đúng phiên bạn chọn, kể cả phiên bạn tự mở ngoài HQ." }, + { id: "role", label: "Theo vai trò", icon: SparklesIcon, hint: "HQ tìm phiên đang chạy có vai này; chưa có thì mở phiên mới." }, + { id: "project", label: "Cả dự án", icon: UsersIcon, hint: "Mọi phiên đang chạy trong dự án cùng nhận một bản." }, +]; function toSchedule(when: string, at: string): Schedule | null { if (when === "once" || when === "daily") return { kind: when, at }; if (when.startsWith("m:") || when.startsWith("h:")) return { kind: "every", n: Number(when.slice(2)), unit: when[0] as "m" | "h" }; return null; } -type Target = `role:${string}` | `session:${string}`; export type AssignDialogProps = { hq: Hq; @@ -39,52 +53,77 @@ export type AssignDialogProps = { type Res = { task: Task; session: string; started: boolean; message: Message; delivery: string; error: string | null }; -/** Hand a task to a role (reuses or starts a session) or to one specific session. */ +/** Hand a task to one session, to whoever has a role (reuse or start a session), or to every session of a project. */ export function AssignDialog({ hq, open, onOpenChange, sessions, projects, defaultRole, defaultSession, defaultProjectId }: AssignDialogProps) { - const live = sessions.filter((s) => s.tmux && s.status !== "ended"); - // Any live session HQ can reach (tmux or inbox) can take a task directly. - const reach = messageTargets(sessions); // A project whose folder is gone cannot host a new session: never offer it. const usable = projects.filter((p) => !p.missing); + const groups = groupTargets(sessions, projects); const [subject, setSubject] = useState(""); const [details, setDetails] = useState(""); - const [target, setTarget] = useState(defaultSession ? `session:${defaultSession}` : `role:${defaultRole ?? "fullstack"}`); + const [mode, setMode] = useState(defaultSession ? "session" : defaultRole != null || groups.length === 0 ? "role" : "session"); + const [session, setSession] = useState(defaultSession ?? ""); + const [role, setRole] = useState(defaultRole ?? "fullstack"); const [prio, setPrio] = useState("P2"); const [when, setWhen] = useState("now"); const [at, setAt] = useState("09:03"); const [projectId, setProjectId] = useState( String(usable.find((p) => p.id === defaultProjectId)?.id ?? usable[0]?.id ?? ""), ); + // "Cả dự án": the scope's project, or every project when HQ shows Everything. + const [fanProject, setFanProject] = useState(defaultProjectId != null ? String(defaultProjectId) : "all"); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const byRole = target.startsWith("role:"); - const role = ROLES.find((r) => `role:${r.id}` === target); - const hasLive = byRole && live.some((s) => s.role === role?.id); + + const picked = groups.flatMap((g) => g.sessions).find((s) => addressOf(s) === session) ?? null; + const roleLive = roleTargets(role, sessions); + const project = usable.find((p) => String(p.id) === projectId) ?? null; + const fan = projects.find((p) => String(p.id) === fanProject) ?? null; + const fanOut = projectTargets(fan?.id ?? null, sessions); + const roleOutside = outsideRole(role, sessions); + const summary = assignSummary({ mode, session: picked, role, roleSessions: roleLive, project: mode === "project" ? fan : project, projectSessions: fanOut }); + const ready = + subject.trim().length > 0 && + (mode === "session" ? picked != null : mode === "role" ? roleLive.length > 0 || project != null : fanOut.length > 0); + + async function post(body: Record): Promise { + const r = await hq.api("/api/tasks", { method: "POST", body }); + hq.mergeTasks([r.task]); + if (r.message) hq.mergeMessages([r.message]); + return r; + } async function submit(e: FormEvent) { e.preventDefault(); - if (busy || !subject.trim()) return; - if (byRole && !hasLive && !projectId) return setError("Chưa có dự án nào còn thư mục: thêm thư mục dự án ở thanh bên trái trước."); + if (busy || !ready) return; setBusy(true); setError(null); + const schedule = toSchedule(when, at); + const base: Record = { subject: subject.trim(), details: details.trim() || undefined, prio, schedule: schedule ?? undefined }; + const plan = schedule ? ` · đã nhờ phiên lên lịch ${scheduleLabel(schedule)} (chưa xác nhận: xem câu trả lời của phiên)` : ""; try { - const [kind, value] = [target.split(":")[0], target.slice(target.indexOf(":") + 1)]; - const schedule = toSchedule(when, at); - const body: Record = { subject: subject.trim(), details: details.trim() || undefined, prio, schedule: schedule ?? undefined }; - if (kind === "session") body.session = value; - else { - body.role = value; - if (projectId) body.project = Number(projectId); - } - const r = await hq.api("/api/tasks", { method: "POST", body }); - hq.mergeTasks([r.task]); - if (r.message) hq.mergeMessages([r.message]); - if (r.delivery === "failed") toast.error(`Đã lưu việc nhưng chưa gửi được: ${r.error}`); - else { - const s = sessions.find((x) => x.name === r.session || x.session_id === r.session); - const who = s ? sessionLabel(s) : r.session.slice(0, 8); - const plan = schedule ? ` · đã nhờ phiên lên lịch ${scheduleLabel(schedule)} (chưa xác nhận: xem câu trả lời của phiên)` : ""; - toast.success(`${r.started ? "Đã mở phiên mới và giao cho" : "Đã giao cho"} ${who}${r.delivery === "inbox" ? " qua inbox (nhận khi xong lượt)" : ""}${plan}`); + if (mode === "project") { + // One task row per session, sent one after another so a 409 (dialog open) names the session. + const failed: string[] = []; + for (const s of fanOut) { + try { + const r = await post({ ...base, session: addressOf(s) }); + if (r.delivery === "failed") failed.push(`${sessionLabel(s)}: ${r.error}`); + } catch (err) { + failed.push(`${sessionLabel(s)}: ${(err as Error).message}`); + } + } + const ok = fanOut.length - failed.length; + if (failed.length) toast.error(`Đã giao cho ${ok}/${fanOut.length} phiên. Chưa gửi được: ${failed.join("; ")}`); + else toast.success(`Đã giao cho ${ok} phiên${fan ? ` trong ${fan.name}` : ""}${plan}`); + } else { + const body = mode === "session" ? { ...base, session } : { ...base, role, ...(project ? { project: project.id } : {}) }; + const r = await post(body); + if (r.delivery === "failed") toast.error(`Đã lưu việc nhưng chưa gửi được: ${r.error}`); + else { + const s = sessions.find((x) => x.name === r.session || x.session_id === r.session); + const who = s ? sessionLabel(s) : r.session.slice(0, 8); + toast.success(`${r.started ? "Đã mở phiên mới và giao cho" : "Đã giao cho"} ${who}${r.delivery === "inbox" ? " qua inbox (nhận khi xong lượt)" : ""}${plan}`); + } } setSubject(""); setDetails(""); @@ -100,115 +139,243 @@ export function AssignDialog({ hq, open, onOpenChange, sessions, projects, defau return ( - -
+ + Giao việc - Chọn một vai trò (dùng phiên đang chạy hoặc mở phiên mới) hoặc một phiên cụ thể. + Việc được gõ thẳng vào phiên nhận; phiên mở ngoài HQ nhận qua inbox khi xong lượt. - -
- - setSubject(e.target.value)} maxLength={300} required autoFocus /> -
-
- -