Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <[email protected]>"]
Expand Down
18 changes: 12 additions & 6 deletions apps/hq-ui/components/hq/assign-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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<AssignMode>(defaultSession ? "session" : defaultRole != null || groups.length === 0 ? "role" : "session");
Expand All @@ -74,7 +78,9 @@ export function AssignDialog({ hq, open, onOpenChange, sessions, projects, defau
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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;
Expand Down Expand Up @@ -220,7 +226,7 @@ export function AssignDialog({ hq, open, onOpenChange, sessions, projects, defau
)}
>
<StatusDot status={s.status} />
<span className="min-w-0 flex-1 truncate font-medium">{sessionLabel(s)}</span>
<span className="min-w-0 flex-1 truncate font-medium">{labelOf(s)}</span>
{s.role && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<RoleIcon role={s.role} /> {ROLES.find((r) => r.id === s.role)?.labelVi ?? s.role}
Expand Down Expand Up @@ -315,7 +321,7 @@ export function AssignDialog({ hq, open, onOpenChange, sessions, projects, defau
<ul className="flex flex-wrap gap-1">
{fanOut.map((s) => (
<li key={addressOf(s)} className="inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs">
<StatusDot status={s.status} /> {sessionLabel(s)}
<StatusDot status={s.status} /> {labelOf(s)}
{s.role && <RoleIcon role={s.role} className="text-muted-foreground" />}
</li>
))}
Expand Down
1 change: 1 addition & 0 deletions apps/hq-ui/components/hq/hq-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
Expand Down
43 changes: 39 additions & 4 deletions apps/hq-ui/lib/assign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,67 @@
// 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<Session, "tmux" | "status" | "name" | "session_id" | "project_id" | "role" | "label">;
type P = Pick<Project, "id" | "name">;
/** 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<T> = { 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<T extends S>(sessions: T[], projects: P[]): SessionGroup<T>[] {
/**
* 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<T extends S>(sessions: T[], projects: P[], rooms: RoomLike[] = []): SessionGroup<T>[] {
const reach = messageTargets(sessions);
const groups: SessionGroup<T>[] = [];
for (const p of projects) {
const inP = reach.filter((s) => s.project_id === p.id);
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<string, SessionGroup<T>>();
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<string, string> {
const count = new Map<string, number>();
for (const s of sessions) count.set(sessionLabel(s), (count.get(sessionLabel(s)) ?? 0) + 1);
const out = new Map<string, string>();
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<T extends S>(role: string, sessions: T[]): T[] {
return sessions.filter((s) => s.tmux && s.status !== "ended" && s.role === role);
Expand Down
36 changes: 35 additions & 1 deletion apps/hq-ui/tests/assign-ui.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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([]);
});
Expand Down
2 changes: 1 addition & 1 deletion assets/claude/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading