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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ versioning theo [SemVer](https://semver.org). **8sync rule:** mỗi PR cập nh

## [Unreleased]

## [0.110.40] — 2026-10-22
### Added
- **Một team cho nhiều dự án: `8sync ccc auto up --company` / `auto on --company`** (owner 2026-10-22: "1 cty rooms/roles agent nhận nhiều dự án … chứ hiện tại mỗi dự án 1 hạm đội riêng"; đo 2026-10-22: 17 phòng rảnh giữ 9,4 GB cho hai dự án). Cờ `company` trong auto.json (giữ qua on/off/pause như `teams`): phòng hết card ở dự án mình thì `auto claim` lấy card gấp nhất (P0 trước, rồi thứ tự scope) ở dự án khác trong scope của auto, `auto wait` thức dậy khi dự án khác có card; card về kèm `root` và hướng dẫn `env -C <root> 8sync ccc auto … --as <phòng>` (board, inbox, grant vẫn theo từng repo; dự án bị pin bằng lease vẫn bị bỏ qua). `auto status --json` thêm `company`. Test auto +1 (round trip, chọn theo prio, bỏ qua dự án pin/không board), wait +2 (card dự án khác, dự án mình ưu tiên). HQ nhóm "Công ty" sẽ ở bản sau.
- **Nút "Ship tất cả" / "Duyệt tất cả" trên cột Approved của Board** (owner 2026-10-22: "kẹt suốt vậy auto ship mà nên có thêm nút ở ví dụ ship all": 21 card approved nằm sau một câu Ship chưa ai bấm). Có câu Ship của lead đang chờ → nút ghi "Duyệt tất cả", một chạm trả lời đúng option đó (như khung Anh cần trả lời; màn hình đổi thì báo mở khung đó); chưa có câu nào → "Ship tất cả" nhắc lead hỏi Ship ngay lượt này (`auto ship-fast ship-ask`) và nút đổi thành "Duyệt tất cả" khi câu hỏi hiện. Test ship-all 2.

## [0.110.39] — 2026-10-22
### Changed
- **Tổng quan gọn lại** (owner 2026-10-22: "ui ux loạn quá"): câu hỏi dài trong khung "Anh cần trả lời" (danh sách 19 card merge của lead) gập còn 3 dòng, bấm vào mở đủ; bỏ dòng "Màn hình phòng đang hiện đúng câu này" và dòng nhắc Ship khi đã có nút đáp án; cột Preview ẩn khi chưa có ảnh (tab Preview vẫn chỉ chỗ lưu ảnh).
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.110.39"
version = "0.110.40"
edition = "2021"
license = "PolyForm-Noncommercial-1.0.0"
authors = ["8sync-dev <[email protected]>"]
Expand Down
8 changes: 7 additions & 1 deletion apps/hq-ui/components/hq/board-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { addressOf, inboxText, messageTargets, sessionLabel } from "@/lib/labels";
import type { ShipRoom } from "@/lib/ship-all";
import { cn } from "@/lib/utils";
import { EmptyState, ErrorNote, ago, shortName } from "./bits";
import { LanesView } from "./lanes-view";
Expand All @@ -24,6 +25,8 @@ export function BoardView({
boards,
focus,
repo = null,
rooms,
token,
}: {
hq: Hq;
tasks: Task[];
Expand All @@ -32,6 +35,9 @@ export function BoardView({
/** A card another tab asked to open: shows the project cards and opens it. */
focus?: { root: string; id: number; n: number } | null;
repo?: string | null;
/** Office rooms + token: the Approved column's "Ship tất cả" answers the project's pending Ship question. */
rooms?: ShipRoom[];
token?: string;
}) {
const [mode, setMode] = useState<"lanes" | "tasks">("lanes");
// A new "Mở card" request always lands on the project cards.
Expand All @@ -51,7 +57,7 @@ export function BoardView({
{tab("lanes", "Card dự án")}
{tab("tasks", "Task của session")}
</div>
<div className="min-h-0 flex-1">{mode === "lanes" ? <LanesView state={boards} focus={focus} repo={repo} /> : <TaskBoard hq={hq} tasks={tasks} sessions={sessions} />}</div>
<div className="min-h-0 flex-1">{mode === "lanes" ? <LanesView state={boards} focus={focus} repo={repo} rooms={rooms} token={token} /> : <TaskBoard hq={hq} tasks={tasks} sessions={sessions} />}</div>
</div>
);
}
Expand Down
2 changes: 1 addition & 1 deletion apps/hq-ui/components/hq/hq-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ export function HqApp({ token, ui }: { token: string; ui: string | null }) {
<MachineView m={machine.data} reload={machine.reload} api={hq.api} rooms={officeRooms} active={tab === "may"} />
</TabsContent>
<TabsContent value="board" className="h-full">
<BoardView hq={hq} tasks={view.tasks} sessions={view.sessions} boards={boards} focus={focusCard} repo={repo?.root ?? null} />
<BoardView hq={hq} tasks={view.tasks} sessions={view.sessions} boards={boards} focus={focusCard} repo={repo?.root ?? null} rooms={officeRooms} token={token} />
</TabsContent>
<TabsContent value="preview" className="h-full">
<PreviewView state={previews} boards={boards.boards} token={token} rooms={officeRooms} onOpenCard={openCard} repo={repo?.root ?? null} />
Expand Down
64 changes: 62 additions & 2 deletions apps/hq-ui/components/hq/lanes-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,18 @@ import { cardParts, type Kid, nextUp, prRef, splitInto } from "@/lib/card-tags";
import { LANES, boardSummary, claimStale, openCount, rankOf, type Card, type Closed, type LaneKey } from "@/lib/lanes";
import { agoShort, agoVi, roomRole, type Move } from "@/lib/now";
import { rootKey } from "@/lib/preview";
import { shipAskOf, type ShipAsk, type ShipRoom } from "@/lib/ship-all";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { ApiError, makeApi } from "./use-hq";
import { EmptyState, ErrorNote } from "./bits";
import { CardSheet, ClosedSheet, ForceButton, LabelChip, PrioChip, TagChips, TONE_INK, forceKind, laneTone } from "./card-sheet";
import { RoleIcon } from "./role-badge";
import { LaneStrip } from "./scroll-strip";
import type { BoardsState } from "./use-board";

/** The project board (`8sync ccc auto board`): which room holds which card, lane by lane. */
export function LanesView({ state, focus, repo = null }: { state: BoardsState; focus?: { root: string; id: number; n: number } | null; repo?: string | null }) {
export function LanesView({ state, focus, repo = null, rooms = [], token }: { state: BoardsState; focus?: { root: string; id: number; n: number } | null; repo?: string | null; rooms?: ShipRoom[]; token?: string }) {
const { boards, error, moves, at: now, reload: load } = state;
const [pick, setPick] = useState<string | null>(repo);
useFollowRepo(repo, setPick);
Expand Down Expand Up @@ -73,6 +76,10 @@ export function LanesView({ state, focus, repo = null }: { state: BoardsState; f
const live = open ? boards?.find((b) => b.root === open.root)?.board : null;
const shown = open ? ((live ? LANES.flatMap((l) => live.lanes[l.key]).find((c) => c.id === open.card.id) : undefined) ?? open.card) : null;
const onForce = cur ? (c: Card) => state.force(cur.root, c) : undefined;
// "Ship tất cả" (owner 2026-10-22: "kẹt suốt vậy auto ship mà nên có thêm nút"): the project's pending Ship
// question, answered with "Duyệt tất cả" in one tap; none pending → the lead is told to ask it now (ship-ask).
const shipAsk = cur && token ? shipAskOf(rooms, cur.root) : null;
const onShip = cur && token ? () => shipAll(token, cur.root, shipAsk, load) : undefined;
const onOpenCard = (c: Card) => {
if (!cur) return;
setOpen({ root: cur.root, card: c });
Expand Down Expand Up @@ -136,7 +143,7 @@ export function LanesView({ state, focus, repo = null }: { state: BoardsState; f
)}
>
{LANES.flatMap((l) => {
const lane = <Lane key={l.key} lane={l.key} label={l.label} hint={l.hint} cards={cur?.board?.lanes[l.key] ?? null} moveOf={moveOf} kidsOf={(id) => kids?.get(id)} now={now} next={next} onOpen={onOpenCard} onForce={onForce} />;
const lane = <Lane key={l.key} lane={l.key} label={l.label} hint={l.hint} cards={cur?.board?.lanes[l.key] ?? null} moveOf={moveOf} kidsOf={(id) => kids?.get(id)} now={now} next={next} onOpen={onOpenCard} onForce={onForce} ship={l.key === "approved" && onShip ? { pending: shipAsk, act: onShip } : undefined} />;
return l.key === "blocked"
? [
<DoneLane
Expand Down Expand Up @@ -241,6 +248,55 @@ const laneLabel = (k: string) => LANES.find((l) => l.key === k)?.label ?? (k ===
/** Amber text that passes AA on light surfaces (same mix as idle-chip). */
const NEEDS_INK = "text-[color-mix(in_oklch,var(--st-needs),black_38%)] dark:text-st-needs";

/**
* One tap on the Approved column. A Ship question pending → answer it with "Duyệt tất cả" (the same keys the
* ask panel sends; the answer API checks the room's screen first). None pending → `auto ship-fast ship-ask`:
* the lead asks it this turn and the button turns into "Duyệt tất cả" when the question shows.
*/
async function shipAll(token: string, root: string, pending: ShipAsk | null, after: () => void): Promise<void> {
const api = makeApi(token);
if (pending) {
const path = `/api/sessions/${encodeURIComponent(pending.sid)}/answer`;
try {
const d = await api<{ dialog: { text: string } }>(path, { method: "POST", body: { kind: "question", keys: [] } });
await api(path, { method: "POST", body: { kind: "question", keys: [pending.key], text: d.dialog.text } });
toast.success(`Đã chọn “${pending.label}”: lead land các card approved (auto merge)`);
} catch (e) {
toast.error(e instanceof ApiError && e.status === 409 ? "Màn hình phòng đã đổi: trả lời ở khung Anh cần trả lời" : (e as Error).message);
}
} else {
const wait = toast.loading("Đang nhắc lead hỏi Ship…");
try {
await api("/api/team", { method: "POST", body: { root, action: "ship-ask" } });
toast.success("Đã nhắc lead hỏi Ship ngay lượt này", { id: wait, description: "Câu hỏi hiện ở khung Anh cần trả lời; nút này thành “Duyệt tất cả” khi lead hỏi." });
} catch (e) {
toast.error("Không nhắc được lead", { id: wait, description: (e as Error).message });
}
}
after();
}

function ShipAllButton({ pending, n, act }: { pending: ShipAsk | null; n: number; act: () => Promise<void> }) {
const [busy, setBusy] = useState(false);
return (
<div className="px-3 pb-2">
<Button
type="button"
size="sm"
className="w-full"
disabled={busy}
onClick={() => {
setBusy(true);
void act().finally(() => setBusy(false));
}}
title={pending ? `Trả lời câu Ship của lead bằng “${pending.label}”: ${n} card approved được merge/tag/release theo luật của card` : `Chưa có câu Ship nào chờ: nhắc lead gom ${n} card approved thành MỘT câu Ship ngay lượt này (auto ship-fast ship-ask)`}
>
<ShipIcon aria-hidden /> {pending ? "Duyệt tất cả" : "Ship tất cả"}
</Button>
</div>
);
}

function Lane({
lane,
label,
Expand All @@ -252,11 +308,14 @@ function Lane({
next,
onOpen,
onForce,
ship,
}: {
lane: LaneKey;
label: string;
hint: string;
cards: Card[] | null;
/** Approved only: the one-tap Ship (pending question → approve all; none → ask the lead now). */
ship?: { pending: ShipAsk | null; act: () => Promise<void> };
moveOf: (id: number) => Move | undefined;
kidsOf: (id: number) => Kid[] | undefined;
now: number;
Expand Down Expand Up @@ -294,6 +353,7 @@ function Lane({
</span>
</h3>
<p className={cn("px-3 pt-0.5 pb-2 text-[11px] text-muted-foreground", loud && cn("font-medium", TONE_INK))}>{hint}</p>
{ship && loud && <ShipAllButton pending={ship.pending} n={n} act={ship.act} />}
<ul className="min-h-0 flex-1 space-y-1.5 overflow-y-auto px-1.5 pb-2">
{cards === null ? (
[0, 1].map((i) => (
Expand Down
34 changes: 34 additions & 0 deletions apps/hq-ui/lib/ship-all.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// "Ship tất cả" on the Approved column (owner 2026-10-22: "kẹt suốt vậy auto ship mà nên có thêm nút ở
// ví dụ ship all"): 21 approved cards sat while the lead's Ship question waited in the ask panel.
// Pure rules here; the button in components/hq/lanes-view.tsx does the posting. Tested in tests/ship-all.test.ts.
import type { Ask } from "./ask";
import { rootKey } from "./preview";

export type ShipRoom = { session_id: string | null; delivery?: "terminal" | "inbox" | null; project: { root: string } | null; ask: Ask | null };

/** The Ship question a project's room holds open for the owner, and the option that approves everything. */
export type ShipAsk = { sid: string; label: string; key: string };

/** `Ship` header, one single-choice question with options: what one tap may answer (see ask-panel oneTapKind). */
export function shipQuestion(a: Ask | null | undefined) {
if (a?.kind !== "question" || a.questions.length !== 1) return null;
const q = a.questions[0];
if ((q.header ?? "").trim().toLowerCase() !== "ship" || q.multi || q.options.length === 0) return null;
return q;
}

/** The pending Ship question of the project at `root` (a terminal room cannot be answered from the web). */
export function shipAskOf<T extends ShipRoom>(rooms: T[], root: string): ShipAsk | null {
const k = rootKey(root);
for (const r of rooms) {
if (!r.session_id || r.delivery === "terminal" || !r.project || rootKey(r.project.root) !== k) continue;
const q = shipQuestion(r.ask);
if (!q) continue;
// "Duyệt tất cả (Recommended)" by name, else the recommended one, else the first.
let i = q.options.findIndex((o) => /duyệt tất cả/i.test(o.label));
if (i < 0) i = q.options.findIndex((o) => /recommended/i.test(o.label));
if (i < 0) i = 0;
return { sid: r.session_id, label: q.options[i].label, key: String(i + 1) };
}
return null;
}
31 changes: 31 additions & 0 deletions apps/hq-ui/tests/ship-all.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import type { Ask } from "@/lib/ask";
import { shipAskOf, shipQuestion } from "@/lib/ship-all";

const ship = (labels: string[], header = "Ship", multi = false): Ask => ({
kind: "question",
at: 1,
questions: [{ header, question: "Merge 21 card?", multi, options: labels.map((label) => ({ label, description: null })) }],
});
const room = (sid: string | null, root: string, ask: Ask | null, delivery: "terminal" | "inbox" | null = "inbox") => ({ session_id: sid, delivery, project: { root }, ask });

describe("Ship tất cả on the Approved column (owner 2026-10-22: 21 approved cards sat behind an unanswered Ship question)", () => {
it("finds the project's pending Ship question and the option that approves everything", () => {
const rooms = [
room("other", "C:/x/other", ship(["Duyệt tất cả (Recommended)", "Dừng"])),
room("lead", "C:/x/app", ship(["Chỉ merge, chưa deploy", "Duyệt tất cả (Recommended)", "Dừng"])),
];
expect(shipAskOf(rooms, "C:/x/app")).toEqual({ sid: "lead", label: "Duyệt tất cả (Recommended)", key: "2" });
expect(shipAskOf(rooms, "c:\\x\\app\\")).toEqual({ sid: "lead", label: "Duyệt tất cả (Recommended)", key: "2" });
expect(shipAskOf(rooms, "C:/x/none")).toBeNull();
});
it("falls back to the recommended option, then the first; skips terminal rooms and non-Ship questions", () => {
expect(shipAskOf([room("a", "/r", ship(["Go (Recommended)", "Stop"]))], "/r")?.key).toBe("1");
expect(shipAskOf([room("a", "/r", ship(["Stop", "Go"]))], "/r")?.key).toBe("1");
expect(shipAskOf([room("a", "/r", ship(["Duyệt tất cả"]), "terminal")], "/r")).toBeNull();
expect(shipAskOf([room(null, "/r", ship(["Duyệt tất cả"]))], "/r")).toBeNull();
expect(shipAskOf([room("a", "/r", ship(["Duyệt tất cả"], "Team"))], "/r")).toBeNull();
expect(shipQuestion(ship(["A", "B"], "Ship", true))).toBeNull();
expect(shipQuestion(null)).toBeNull();
});
});
2 changes: 1 addition & 1 deletion assets/claude/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "8sync-kit",
"displayName": "8sync kit",
"version": "0.110.39",
"version": "0.110.40",
"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://get.8syncdev.com",
Expand Down
Loading
Loading