diff --git a/CHANGELOG.md b/CHANGELOG.md index bf744456..834529ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ versioning theo [SemVer](https://semver.org). **8sync rule:** mỗi PR cập nh ## [Unreleased] +## [0.92.0] — 2026-10-15 +### 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 - `8sync learn trending` run from inside a project's `su-code/` memory directory (which carries its own AGENTS.md) wrote to `su-code/su-code/research/trending/`; the trending dir is now resolved by `trending_dir()`: a `su-code` memory dir without a VCS/manifest of its own belongs to its parent project, a repo that is itself named `su-code` stays a normal root, and any subfolder of the project resolves to `/su-code/research/trending/.md` (test `learn_trending_dir_resolves_from_memory_dir_and_subfolders`; reported by the R&D session in UAT of v0.91.0). diff --git a/Cargo.lock b/Cargo.lock index ece94abe..c92353c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -194,7 +194,7 @@ dependencies = [ [[package]] name = "ccc-core" -version = "0.91.1" +version = "0.92.0" dependencies = [ "anyhow", "serde", @@ -1398,7 +1398,7 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "su-code" -version = "0.91.1" +version = "0.92.0" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 501c6b0e..947d05c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/cli", "crates/ccc-core"] [workspace.package] -version = "0.91.1" +version = "0.92.0" edition = "2021" license = "MIT" authors = ["8sync-dev <8sync.dev.1111@gmail.com>"] 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 /> -
-
- -