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.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 `<project>/su-code/research/trending/<date>.md` (test `learn_trending_dir_resolves_from_memory_dir_and_subfolders`; reported by the R&D session in UAT of v0.91.0).
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.91.1"
version = "0.92.0"
edition = "2021"
license = "MIT"
authors = ["8sync-dev <[email protected]>"]
Expand Down
440 changes: 310 additions & 130 deletions apps/hq-ui/components/hq/assign-dialog.tsx

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/hq-ui/components/hq/guide.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const GUIDE_STEPS: GuideStep[] = [
{
icon: SendIcon,
title: "Giao việc",
body: "Bấm \"Giao việc\", gõ việc cần làm, rồi chọn một vai trò (hệ thống tìm người rảnh) hoặc một người cụ thể.",
body: "Bấm \"Giao việc\", gõ việc cần làm, rồi chọn cách giao: một phiên cụ thể, theo vai trò (HQ tìm phiên đang chạy có vai đó, chưa có thì mở mới) hoặc cả dự án (mọi phiên đang chạy cùng nhận). Dòng cuối dialog nói rõ việc sẽ đi đâu.",
},
{
icon: TerminalIcon,
Expand Down
16 changes: 13 additions & 3 deletions apps/hq-ui/components/hq/role-badge.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
import * as Icons from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { modelShort, roleOf } from "@/lib/roles";
import { modelShort, roleOf, ROLES } from "@/lib/roles";

// Resolved once at module load: the icon components must not be picked during render.
const ICON_BY_ROLE: Record<string, LucideIcon> = Object.fromEntries(
ROLES.map((r) => [r.id, (Icons as unknown as Record<string, LucideIcon>)[r.icon] ?? Icons.CircleIcon]),
);

/** The lucide icon of a role (CircleIcon for an unknown one). */
export function RoleIcon({ role, className }: { role: string | null | undefined; className?: string }) {
const Icon = ICON_BY_ROLE[role ?? ""] ?? Icons.CircleIcon;
return <Icon aria-hidden className={cn("size-3 shrink-0", className)} />;
}

/** Icon + VN label + model short name. Never colour alone. */
export function RoleBadge({ role, model, className }: { role: string | null | undefined; model?: string | null; className?: string }) {
const r = roleOf(role);
if (!r) return null;
const Icon = (Icons as unknown as Record<string, LucideIcon>)[r.icon] ?? Icons.CircleIcon;
const m = model ?? r.model;
return (
<span
className={cn("inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs", className)}
title={`${r.labelVi} · ${modelShort(m)}`}
>
<Icon aria-hidden className="size-3 shrink-0" />
<RoleIcon role={r.id} />
<span>{r.labelVi}</span>
<span className="text-muted-foreground">· {modelShort(m)}</span>
</span>
Expand Down
82 changes: 82 additions & 0 deletions apps/hq-ui/lib/assign.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Pure rules behind the "Giao việc" dialog (no React). Tested in tests/assign-ui.test.ts.
//
// Owner (2026-10-15): the old dialog mixed roles and sessions in one dropdown, so it was unclear
// 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 { 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">;

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>[] {
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));
if (rest.length) groups.push({ projectId: null, name: OTHER_GROUP, sessions: rest });
return groups;
}

/** 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);
}

/** Reachable sessions that carry the role but were started outside HQ: role routing cannot reach them, "Một phiên" can. */
export function outsideRole<T extends S>(role: string, sessions: T[]): T[] {
return messageTargets(sessions).filter((s) => !s.tmux && s.role === role);
}

/** Every reachable session of a project (null = every project): the "Cả dự án" fan-out. */
export function projectTargets<T extends S>(projectId: number | null, sessions: T[]): T[] {
return messageTargets(sessions).filter((s) => projectId == null || s.project_id === projectId);
}

/** "rd, web, zus-core" (max `n` names, then "+k"). */
export function nameList(sessions: S[], n = 4): string {
const names = sessions.map(sessionLabel);
return names.length <= n ? names.join(", ") : `${names.slice(0, n).join(", ")} +${names.length - n}`;
}

export type Summary = {
mode: AssignMode;
session?: S | null;
role?: string | null;
roleSessions?: S[];
project?: P | null;
projectSessions?: S[];
};

/** One line that says exactly where the task will go, or "" when nothing is chosen yet. */
export function assignSummary(x: Summary): string {
if (x.mode === "session") {
if (!x.session) return "";
const via = deliveryOf(x.session) === "inbox" ? "qua inbox (nhận khi xong lượt)" : "vào terminal";
return `Giao cho phiên ${sessionLabel(x.session)} ${via}`;
}
if (x.mode === "role") {
const r = roleOf(x.role);
if (!r) return "";
const live = x.roleSessions ?? [];
if (live.length === 0) return `Chưa có phiên ${r.labelVi}: mở phiên mới (${modelShort(r.model)}) trong ${x.project?.name ?? "dự án đã chọn"}`;
if (live.length === 1) return `Giao cho phiên ${sessionLabel(live[0])} (${r.labelVi} duy nhất đang chạy)`;
return `Giao cho phiên ${r.labelVi} rảnh lâu nhất trong ${live.length} phiên: ${nameList(live)}`;
}
const all = x.projectSessions ?? [];
const where = x.project ? `trong ${x.project.name}` : "ở mọi dự án";
if (all.length === 0) return `Chưa có phiên nào đang chạy ${where}`;
return `Giao cùng một việc cho ${all.length} phiên ${where}: ${nameList(all)}`;
}
93 changes: 93 additions & 0 deletions apps/hq-ui/tests/assign-ui.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { assignSummary, groupTargets, nameList, outsideRole, projectTargets, roleTargets } from "@/lib/assign";

const SID = "11111111-2222-3333-4444-555555555555";
const SID2 = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
const s = (name: string, o: Partial<{ tmux: boolean; status: "running" | "idle" | "needs_input" | "ended"; session_id: string | null; project_id: number | null; role: string | null; label: string }> = {}) => ({
name,
tmux: true,
status: "running" as const,
session_id: null as string | null,
project_id: 1 as number | null,
role: null as string | null,
label: "",
...o,
});
const projects = [
{ id: 1, name: "levels-repo" },
{ id: 2, name: "8sync-startup" },
];

describe("groupTargets", () => {
it("groups reachable sessions by project in project order, unreachable and ended dropped, orphans last", () => {
const g = groupTargets(
[
s("rd", { tmux: false, session_id: SID, project_id: 2 }),
s("hq-rd-1", { role: "rd" }),
s("core", { tmux: false, session_id: null }), // neither tmux nor a session id: unreachable
s("hq-old", { status: "ended" }),
s("web", { tmux: false, session_id: SID2, project_id: null }),
s("hq-x", { project_id: 99 }), // project no longer listed
],
projects,
);
expect(g.map((x) => [x.name, x.sessions.map((y) => y.name)])).toEqual([
["levels-repo", ["hq-rd-1"]],
["8sync-startup", ["rd"]],
["Phiên khác", ["web", "hq-x"]],
]);
});
it("is empty when nothing is reachable", () => {
expect(groupTargets([s("hq-old", { status: "ended" })], projects)).toEqual([]);
});
});

describe("roleTargets / projectTargets", () => {
const all = [
s("hq-rd-1", { role: "rd" }),
s("hq-rd-2", { role: "rd", status: "ended" }),
s("rd", { tmux: false, session_id: SID, role: "rd" }), // started outside HQ: the server cannot route by role to it
s("hq-web-1", { role: "fullstack", project_id: 2 }),
];
it("roleTargets: live hq sessions with that role only", () => {
expect(roleTargets("rd", all).map((x) => x.name)).toEqual(["hq-rd-1"]);
expect(roleTargets("tester", all)).toEqual([]);
});
it("outsideRole: reachable non-hq sessions that carry the role", () => {
expect(outsideRole("rd", all).map((x) => x.name)).toEqual(["rd"]);
expect(outsideRole("fullstack", all)).toEqual([]);
});
it("projectTargets: every reachable session of the project, or of all projects", () => {
expect(projectTargets(1, all).map((x) => x.name)).toEqual(["hq-rd-1", "rd"]);
expect(projectTargets(2, all).map((x) => x.name)).toEqual(["hq-web-1"]);
expect(projectTargets(null, all).map((x) => x.name)).toEqual(["hq-rd-1", "rd", "hq-web-1"]);
});
});

describe("assignSummary", () => {
it("session: names the session and how it is reached", () => {
expect(assignSummary({ mode: "session", session: s("hq-rd-1") })).toBe("Giao cho phiên rd-1 vào terminal");
expect(assignSummary({ mode: "session", session: s("rd", { tmux: false, session_id: SID }) })).toBe("Giao cho phiên rd qua inbox (nhận khi xong lượt)");
expect(assignSummary({ mode: "session", session: null })).toBe("");
});
it("role: reuse one, pick among many, or start a new session in the project", () => {
expect(assignSummary({ mode: "role", role: "rd", roleSessions: [s("hq-rd-1")] })).toBe("Giao cho phiên rd-1 (R&D (nghiên cứu) duy nhất đang chạy)");
expect(assignSummary({ mode: "role", role: "rd", roleSessions: [s("hq-rd-1"), s("hq-rd-2")] })).toBe(
"Giao cho phiên R&D (nghiên cứu) rảnh lâu nhất trong 2 phiên: rd-1, rd-2",
);
expect(assignSummary({ mode: "role", role: "tester", roleSessions: [], project: projects[0] })).toBe(
"Chưa có phiên Tester: mở phiên mới (Haiku 5.5) trong levels-repo",
);
expect(assignSummary({ mode: "role", role: "nope" })).toBe("");
});
it("project: counts the fan-out and says when there is nobody", () => {
expect(assignSummary({ mode: "project", project: projects[0], projectSessions: [s("hq-rd-1"), s("hq-web-1")] })).toBe(
"Giao cùng một việc cho 2 phiên trong levels-repo: rd-1, web-1",
);
expect(assignSummary({ mode: "project", project: null, projectSessions: [s("a")] })).toBe("Giao cùng một việc cho 1 phiên ở mọi dự án: a");
expect(assignSummary({ mode: "project", project: projects[1], projectSessions: [] })).toBe("Chưa có phiên nào đang chạy trong 8sync-startup");
});
it("nameList caps long lists", () => {
expect(nameList([s("a"), s("b"), s("c"), s("d"), s("e"), s("f")])).toBe("a, b, c, d +2");
});
});
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.91.1",
"version": "0.92.0",
"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