From f87290d27ff1c6b4483ca86bd0ab8a127f2e4725 Mon Sep 17 00:00:00 2001 From: 8syncdev Date: Thu, 15 Oct 2026 16:18:13 +0700 Subject: [PATCH 1/2] =?UTF-8?q?fix(hq-ui):=20Giao=20vi=E1=BB=87c=20groups?= =?UTF-8?q?=20sessions=20like=20the=20sidebar=20and=20disambiguates=20dupl?= =?UTF-8?q?icate=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions started outside a registered project are grouped by the git folder their office room reports ("su-code ⎇ main"), as the sidebar does, instead of all landing under "Phiên khác". Two live sessions with the same name show " · <8 id chars>" in the list, the fan-out chips and the summary line so the owner can tell which one a task goes to. Co-Authored-By: Claude Fable 5.1 --- apps/hq-ui/components/hq/assign-dialog.tsx | 18 ++++++--- apps/hq-ui/components/hq/hq-app.tsx | 1 + apps/hq-ui/lib/assign.ts | 43 ++++++++++++++++++++-- apps/hq-ui/tests/assign-ui.test.ts | 36 +++++++++++++++++- 4 files changed, 87 insertions(+), 11 deletions(-) diff --git a/apps/hq-ui/components/hq/assign-dialog.tsx b/apps/hq-ui/components/hq/assign-dialog.tsx index 5615d4ad..3078332f 100644 --- a/apps/hq-ui/components/hq/assign-dialog.tsx +++ b/apps/hq-ui/components/hq/assign-dialog.tsx @@ -10,7 +10,7 @@ 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 { assignSummary, distinctLabels, groupTargets, outsideRole, projectTargets, roleTargets, type AssignMode, type RoomLike } 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"; @@ -45,6 +45,8 @@ export type AssignDialogProps = { onOpenChange: (open: boolean) => void; sessions: Session[]; projects: Project[]; + /** Office rooms (`/api/office`): sessions outside registered projects are grouped by the git folder they report. */ + rooms?: RoomLike[]; /** Preselect a role, a session, or the project used when a role session must be started. */ defaultRole?: RoleId; defaultSession?: string; @@ -54,10 +56,12 @@ export type AssignDialogProps = { type Res = { task: Task; session: string; started: boolean; message: Message; delivery: string; error: string | null }; /** 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) { +export function AssignDialog({ hq, open, onOpenChange, sessions, projects, rooms, defaultRole, defaultSession, defaultProjectId }: AssignDialogProps) { // 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 groups = groupTargets(sessions, projects, rooms); + const labels = distinctLabels(groups.flatMap((g) => g.sessions)); + const labelOf = (s: Session) => labels.get(addressOf(s)) ?? sessionLabel(s); const [subject, setSubject] = useState(""); const [details, setDetails] = useState(""); const [mode, setMode] = useState(defaultSession ? "session" : defaultRole != null || groups.length === 0 ? "role" : "session"); @@ -74,7 +78,9 @@ export function AssignDialog({ hq, open, onOpenChange, sessions, projects, defau const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const picked = groups.flatMap((g) => g.sessions).find((s) => addressOf(s) === session) ?? null; + const found = groups.flatMap((g) => g.sessions).find((s) => addressOf(s) === session); + // The summary names the session the way the list does ("rd · 1a2b3c4d" when two rooms are called rd). + const picked = found ? { ...found, label: labelOf(found) } : 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; @@ -220,7 +226,7 @@ export function AssignDialog({ hq, open, onOpenChange, sessions, projects, defau )} > - {sessionLabel(s)} + {labelOf(s)} {s.role && ( {ROLES.find((r) => r.id === s.role)?.labelVi ?? s.role} @@ -315,7 +321,7 @@ export function AssignDialog({ hq, open, onOpenChange, sessions, projects, defau
    {fanOut.map((s) => (
  • - {sessionLabel(s)} + {labelOf(s)} {s.role && }
  • ))} diff --git a/apps/hq-ui/components/hq/hq-app.tsx b/apps/hq-ui/components/hq/hq-app.tsx index 27fef720..9a87c7b6 100644 --- a/apps/hq-ui/components/hq/hq-app.tsx +++ b/apps/hq-ui/components/hq/hq-app.tsx @@ -266,6 +266,7 @@ export function HqApp({ token }: { token: string }) { onOpenChange={(open) => setAssign((a) => ({ ...a, open }))} sessions={hq.sessions.data} projects={hq.projects.data} + rooms={office.office?.rooms} defaultSession={assign.session ?? (scoped && deliveryOf(scoped) && scoped.status !== "ended" ? addressOf(scoped) : undefined)} defaultProjectId={scope.project} /> diff --git a/apps/hq-ui/lib/assign.ts b/apps/hq-ui/lib/assign.ts index 4e79e0d6..a5ca753d 100644 --- a/apps/hq-ui/lib/assign.ts +++ b/apps/hq-ui/lib/assign.ts @@ -4,20 +4,25 @@ // whether a task went to one session, to whoever has a role, or to every session of a repo. // The dialog now has three explicit modes and one summary line that says exactly where it goes. import type { Project, Session } from "@/components/hq/types"; -import { deliveryOf, messageTargets, sessionLabel } from "./labels"; +import { addressOf, deliveryOf, messageTargets, sessionLabel } from "./labels"; import { roleOf, modelShort } from "./roles"; export type AssignMode = "session" | "role" | "project"; type S = Pick; type P = Pick; +/** The office room of a session (`/api/office`): the git folder + branch it reports, like the sidebar uses. */ +export type RoomLike = { name: string; session_id: string | null; project: { root: string; name: string; branch?: string | null } | null }; export type SessionGroup = { projectId: number | null; name: string; sessions: T[] }; export const OTHER_GROUP = "Phiên khác"; -/** Reachable live sessions grouped by project, projects in the given order; sessions without a project last. */ -export function groupTargets(sessions: T[], projects: P[]): SessionGroup[] { +/** + * Reachable live sessions grouped like the sidebar: registered projects first (in the given order), + * then sessions outside them by the git folder their room reports ("su-code ⎇ main"), then the rest. + */ +export function groupTargets(sessions: T[], projects: P[], rooms: RoomLike[] = []): SessionGroup[] { const reach = messageTargets(sessions); const groups: SessionGroup[] = []; for (const p of projects) { @@ -25,11 +30,41 @@ export function groupTargets(sessions: T[], projects: P[]): Session if (inP.length) groups.push({ projectId: p.id, name: p.name, sessions: inP }); } const known = new Set(projects.map((p) => p.id)); - const rest = reach.filter((s) => s.project_id == null || !known.has(s.project_id)); + const folders = new Map>(); + const rest: T[] = []; + for (const s of reach) { + if (s.project_id != null && known.has(s.project_id)) continue; + const room = rooms.find((r) => r.name === s.name || (s.session_id != null && r.session_id === s.session_id)); + const p = room?.project; + if (!p) { + rest.push(s); + continue; + } + const key = p.root.toLowerCase(); + if (!folders.has(key)) folders.set(key, { projectId: null, name: p.branch ? `${p.name} ⎇ ${p.branch}` : p.name, sessions: [] }); + folders.get(key)!.sessions.push(s); + } + groups.push(...[...folders.values()].sort((a, b) => a.name.localeCompare(b.name))); if (rest.length) groups.push({ projectId: null, name: OTHER_GROUP, sessions: rest }); return groups; } +/** + * Display label per session address; two sessions with the same label (two "rd" rooms) get + * " · <8 id chars>" so the owner can tell which one a task goes to. + */ +export function distinctLabels(sessions: S[]): Map { + const count = new Map(); + for (const s of sessions) count.set(sessionLabel(s), (count.get(sessionLabel(s)) ?? 0) + 1); + const out = new Map(); + for (const s of sessions) { + const label = sessionLabel(s); + const id = (s.session_id ?? s.name).slice(0, 8); + out.set(addressOf(s), (count.get(label) ?? 0) > 1 && id !== label ? `${label} · ${id}` : label); + } + return out; +} + /** The live hq sessions a role assignment can reuse (server picks the least-recently-active one). */ export function roleTargets(role: string, sessions: T[]): T[] { return sessions.filter((s) => s.tmux && s.status !== "ended" && s.role === role); diff --git a/apps/hq-ui/tests/assign-ui.test.ts b/apps/hq-ui/tests/assign-ui.test.ts index 0028f736..ef8480ef 100644 --- a/apps/hq-ui/tests/assign-ui.test.ts +++ b/apps/hq-ui/tests/assign-ui.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { assignSummary, groupTargets, nameList, outsideRole, projectTargets, roleTargets } from "@/lib/assign"; +import { assignSummary, distinctLabels, groupTargets, nameList, outsideRole, projectTargets, roleTargets } from "@/lib/assign"; const SID = "11111111-2222-3333-4444-555555555555"; const SID2 = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; @@ -37,6 +37,40 @@ describe("groupTargets", () => { ["Phiên khác", ["web", "hq-x"]], ]); }); + it("groups sessions outside registered projects by the git folder their room reports, like the sidebar", () => { + const rooms = [ + { name: "x", session_id: SID, project: { root: "/home/u/startup", name: "startup", branch: "main" } }, + { name: "y", session_id: SID2, project: { root: "/home/u/Startup", name: "startup", branch: "main" } }, // same folder, different case + { name: "hq-x", session_id: null, project: { root: "/home/u/acme", name: "acme", branch: null } }, + ]; + const g = groupTargets( + [ + s("x", { tmux: false, session_id: SID, project_id: null }), + s("y", { tmux: false, session_id: SID2, project_id: 99 }), + s("hq-x", { project_id: null }), + s("z", { tmux: false, session_id: "cccccccc-bbbb-cccc-dddd-eeeeeeeeeeee", project_id: null }), // no room: last + s("hq-rd-1", { role: "rd" }), + ], + projects, + rooms, + ); + expect(g.map((x) => [x.name, x.sessions.map((y) => y.name)])).toEqual([ + ["levels-repo", ["hq-rd-1"]], + ["acme", ["hq-x"]], + ["startup ⎇ main", ["x", "y"]], + ["Phiên khác", ["z"]], + ]); + }); + it("distinctLabels: duplicate names get the first 8 id chars", () => { + const m = distinctLabels([ + s("rd", { tmux: false, session_id: SID, label: "rd" }), + s("rd", { tmux: false, session_id: SID2, label: "rd" }), + s("hq-web-1"), + ]); + expect(m.get(SID)).toBe("rd · 11111111"); + expect(m.get(SID2)).toBe("rd · aaaaaaaa"); + expect(m.get("hq-web-1")).toBe("web-1"); + }); it("is empty when nothing is reachable", () => { expect(groupTargets([s("hq-old", { status: "ended" })], projects)).toEqual([]); }); From 7b73675541163ca8b8640a51ebcf7aefeeb6d09f Mon Sep 17 00:00:00 2001 From: 8syncdev Date: Thu, 15 Oct 2026 16:18:13 +0700 Subject: [PATCH 2/2] =?UTF-8?q?release:=20v0.92.1=20=E2=80=94=20Giao=20vi?= =?UTF-8?q?=E1=BB=87c=20grouping=20+=20duplicate-name=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 ++++ Cargo.lock | 4 ++-- Cargo.toml | 2 +- assets/claude/.claude-plugin/plugin.json | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 834529ba..128ddd93 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.1] — 2026-10-15 +### Fixed +- **HQ "Giao việc" › Một phiên** on real data: every session the owner started outside HQ landed under "Phiên khác", and two rooms with the same name (two `rd`, two `web`) were indistinguishable. The list now groups those sessions by the git folder + branch their office room reports, exactly like the sidebar ("su-code ⎇ main"), and a duplicated name gets its first 8 session-id characters ("rd · 1a2b3c4d") in the list, the fan-out chips and the summary line (`groupTargets(sessions, projects, rooms)`, `distinctLabels`; tests in tests/assign-ui.test.ts). + ## [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). diff --git a/Cargo.lock b/Cargo.lock index c92353c3..26dd4ff5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -194,7 +194,7 @@ dependencies = [ [[package]] name = "ccc-core" -version = "0.92.0" +version = "0.92.1" dependencies = [ "anyhow", "serde", @@ -1398,7 +1398,7 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "su-code" -version = "0.92.0" +version = "0.92.1" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 947d05c4..e3edb18a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/cli", "crates/ccc-core"] [workspace.package] -version = "0.92.0" +version = "0.92.1" edition = "2021" license = "MIT" authors = ["8sync-dev <8sync.dev.1111@gmail.com>"] diff --git a/assets/claude/.claude-plugin/plugin.json b/assets/claude/.claude-plugin/plugin.json index aa3d2453..6fd37875 100644 --- a/assets/claude/.claude-plugin/plugin.json +++ b/assets/claude/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "8sync-kit", - "version": "0.92.0", + "version": "0.92.1", "description": "8 Sync kit for Claude Code: role-routed subagents (explore, task, quick-task, reviewer, designer, researcher, verifier, pm, architect, qa, critic) and the /ship /rules /sec /harden skills. Hooks, rules and the full skill set come from `8sync ccc kit`.", "author": { "name": "8 Sync", "url": "https://8syncdev.com" }, "homepage": "https://github.com/8-Sync-Dev/su-code",