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.110.47] — 2026-10-22
### Added
- **Sidebar HQ: mục "Dự án trên máy"** (owner 2026-10-22: "nên có chỗ hiển thị all dự án trong máy để bấm mở"): mọi repo 8sync từng thấy trên máy (scope auto, team roots, dự án đã đóng, ghim, thư mục của các phiên HQ ghi nhận) mà chưa thêm vào HQ và chưa có phòng, mỗi dòng một nút Mở: thêm vào HQ rồi mở Văn phòng để bấm "Mở team"; không còn phải gõ đường dẫn. `/api/projects` thêm `known` (chỉ thư mục còn tồn tại). Test known-projects 1.

## [0.110.46] — 2026-10-22
### Added
- **Tab 3D: biển tên trên mỗi bàn** (tiếp .43; owner: "ui ux strong realistic working day"): sprite 2 dòng trên bàn ghi tên phòng và card đang giữ (`#12 · qa`), không có card thì "đang làm / chờ anh / kẹt / rảnh" (dòng hai màu cam khi chờ anh); tooltip rê chuột thêm tiêu đề card. Canvas texture 256×96, không tải font, không thêm dependency. Test office-3d +1.
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.46"
version = "0.110.47"
edition = "2021"
license = "PolyForm-Noncommercial-1.0.0"
authors = ["8sync-dev <[email protected]>"]
Expand Down
4 changes: 3 additions & 1 deletion apps/hq-ui/app/api/projects/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import path from "node:path";
import { projectIdFor, withMissing, type ProjectRow } from "@/lib/server/db";
import { bad, json, open, readBody } from "@/lib/server/http";
import { knownProjects } from "@/lib/server/known-projects";
import { validateProjectPath } from "@/lib/server/validate";

export const runtime = "nodejs";
Expand All @@ -9,7 +10,8 @@ export async function GET(req: Request) {
const o = open(req);
if ("res" in o) return o.res;
const rows = o.db.prepare("SELECT id, path, name, added_at FROM projects ORDER BY added_at").all() as ProjectRow[];
return json({ projects: withMissing(rows) });
// `known`: every other repo this machine's 8sync has seen (auto scope, team roots, pins, sessions), for one-tap opening.
return json({ projects: withMissing(rows), known: knownProjects(o.db, rows.map((r) => r.path)) });
}

export async function POST(req: Request) {
Expand Down
44 changes: 44 additions & 0 deletions apps/hq-ui/components/hq/nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { CloseDialog, ClosedProjects, ProjectMenu, RoomEndDialog, RoomMenu, Work
import { RoleIcon } from "./role-badge";
import type { Hq, Live } from "./use-hq";
import type { OfficeState, Room } from "./use-office";
import type { KnownProject } from "@/lib/known-projects";
import type { Project } from "./types";

export type Scope = { project: number | null; session: string | null };
Expand Down Expand Up @@ -86,6 +87,8 @@ export function Nav({
const side = useMemo(() => pinSidebar(data?.pins ?? [], split.shown, split.closed, roomless), [data, split, roomless]);
const { shown, closed } = side;
const heading = projectsHeading(data?.company === true, shown.length);
// "Dự án trên máy": known repos that are neither added folders nor live groups.
const knownRest = useMemo(() => (hq.knownProjects ?? []).filter((k) => !groups.some((g) => g.key === norm(k.path)) && !hq.projects.data.some((p) => norm(p.path) === norm(k.path))), [hq.knownProjects, hq.projects.data, groups]);
const [confirm, setConfirm] = useState<Confirm | null>(null);
// The project whose "Hiện lại" is in flight: its "⋯" and "Hiện lại" stay disabled meanwhile.
const [hiding, setHiding] = useState<string | null>(null);
Expand Down Expand Up @@ -230,6 +233,7 @@ export function Nav({
<ul className="space-y-1">{shown.map((g) => group(g, false))}</ul>
)}
<ClosedProjects closed={closed} busy={hiding} onShow={(g) => void show(g)} />
<KnownProjects items={knownRest} hq={hq} onOpen={onOpenFolder} />
</>
)}

Expand Down Expand Up @@ -550,3 +554,43 @@ function AddProject({ hq, onAdded }: { hq: Hq; onAdded: (id: number) => void })
}

const noop = () => () => {};

/**
* "Dự án trên máy" (owner 2026-10-22): repos 8sync has seen on this machine but the owner never added
* to HQ. One tap adds the folder and opens it in the office, where "Mở team" waits.
*/
function KnownProjects({ items, hq, onOpen }: { items: KnownProject[]; hq: Hq; onOpen: (id: number) => void }) {
const [busy, setBusy] = useState<string | null>(null);
if (!items.length) return null;
async function add(k: KnownProject) {
setBusy(k.path);
try {
const r = await hq.api<{ project: Project; existed?: boolean }>("/api/projects", { method: "POST", body: { path: k.path } });
await hq.reload.projects();
toast.success(`Đã thêm ${r.project.name}: bấm “Mở team” trong Văn phòng`);
onOpen(r.project.id);
} catch (e) {
toast.error((e as Error).message);
} finally {
setBusy(null);
}
}
return (
<section className="mt-3" aria-labelledby="known-projects">
<h3 id="known-projects" className="mb-1 px-2 text-xs font-medium text-muted-foreground" title="Repo máy này từng chạy 8sync (scope auto, team, ghim, phiên cũ) mà chưa thêm vào HQ">
Dự án trên máy · {items.length}
</h3>
<ul className="space-y-1">
{items.map((k) => (
<li key={k.path} className="flex items-center">
<button type="button" className={cn(item, "h-8 min-w-0 flex-1")} disabled={busy === k.path} title={`${k.path} (${k.source}): thêm vào HQ và mở`} onClick={() => void add(k)}>
<FolderIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0 flex-1 truncate">{k.name}</span>
<span className="shrink-0 text-[11px] text-muted-foreground">{busy === k.path ? "…" : "Mở"}</span>
</button>
</li>
))}
</ul>
</section>
);
}
7 changes: 6 additions & 1 deletion apps/hq-ui/components/hq/use-hq.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import type { KnownProject } from "@/lib/known-projects";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import type { ChannelMode, Message, Project, Session, Task } from "./types";
Expand Down Expand Up @@ -97,6 +98,8 @@ const sleep = (ms: number, signal: AbortSignal) =>
export function useHq(token: string) {
const api = useMemo(() => makeApi(token), [token]);
const [projects, setProjects] = useState<Load<Project[]>>(init([]));
// Repos the machine knows but the owner has not added ("Dự án trên máy").
const [known, setKnown] = useState<KnownProject[]>([]);
const [sessions, setSessions] = useState<Load<Session[]>>(init([]));
const [channelMode, setChannelMode] = useState<ChannelMode | null>(null);
const [tasks, setTasks] = useState<Load<Task[]>>(init([]));
Expand All @@ -106,8 +109,9 @@ export function useHq(token: string) {

const loadProjects = useCallback(async () => {
try {
const r = await api<{ projects: Project[] }>("/api/projects");
const r = await api<{ projects: Project[]; known?: KnownProject[] }>("/api/projects");
setProjects({ data: r.projects, loading: false, error: null });
setKnown(r.known ?? []);
} catch (e) {
setProjects((p) => ({ ...p, loading: false, error: (e as Error).message }));
}
Expand Down Expand Up @@ -271,6 +275,7 @@ export function useHq(token: string) {
api,
live,
projects,
knownProjects: known,
sessions,
channelMode,
tasks,
Expand Down
22 changes: 22 additions & 0 deletions apps/hq-ui/lib/known-projects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// "Dự án trên máy" (owner 2026-10-22: "nên có chỗ hiển thị all dự án trong máy để bấm mở"): every repo
// root this machine's 8sync has seen, so a project opens with one tap instead of a typed path. Pure
// merge here; the sources are read in lib/server/known-projects.ts. Tested in tests/known-projects.test.ts.
import { rootKey } from "./preview";

export type KnownProject = { path: string; name: string; /** Where it was seen: auto scope, team root, pin, a session's folder. */ source: string };

const baseName = (p: string) => p.replace(/\\/g, "/").replace(/\/+$/, "").split("/").pop() || p;

/** Dedupe by root (case and slashes ignored), first source wins; `exclude` = roots already listed elsewhere (added folders, live groups). */
export function mergeKnown(found: { path: string; source: string }[], exclude: string[]): KnownProject[] {
const skip = new Set(exclude.map(rootKey));
const seen = new Set<string>();
const out: KnownProject[] = [];
for (const f of found) {
const k = rootKey(f.path);
if (!k || skip.has(k) || seen.has(k)) continue;
seen.add(k);
out.push({ path: f.path, name: baseName(f.path), source: f.source });
}
return out.sort((a, b) => a.name.localeCompare(b.name));
}
30 changes: 30 additions & 0 deletions apps/hq-ui/lib/server/known-projects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Sources of "Dự án trên máy": auto's scope / team roots / closed projects (last `auto status`), pinned
// roots, and the repo of every session HQ has recorded. Only folders that still exist are returned.
import fs from "node:fs";
import type { DatabaseSync } from "node:sqlite";
import { mergeKnown, type KnownProject } from "../known-projects";
import { lastAutoStatus } from "./auto";
import { projectOf, readPins } from "./office";

const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((r): r is string => typeof r === "string" && r.length > 0 && r.length <= 1024) : []);

export function knownProjects(db: DatabaseSync, exclude: string[]): KnownProject[] {
const st = lastAutoStatus();
const found: { path: string; source: string }[] = [];
for (const r of strings(st?.repos)) found.push({ path: r, source: "scope" });
for (const r of strings(st?.team_roots)) found.push({ path: r, source: "team" });
for (const r of strings(st?.out)) found.push({ path: r, source: "closed" });
for (const p of readPins()) found.push({ path: p.root, source: "pin" });
const rows = db.prepare("SELECT DISTINCT cwd FROM sessions WHERE cwd IS NOT NULL").all() as { cwd: string }[];
for (const r of rows) {
const p = projectOf(r.cwd);
if (p) found.push({ path: p.root, source: "session" });
}
return mergeKnown(found, exclude).filter((k) => {
try {
return fs.statSync(k.path).isDirectory();
} catch {
return false;
}
});
}
22 changes: 22 additions & 0 deletions apps/hq-ui/tests/known-projects.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { mergeKnown } from "@/lib/known-projects";

describe("Dự án trên máy (mergeKnown)", () => {
it("dedupes by root, drops excluded ones, names by basename, sorts by name", () => {
const out = mergeKnown(
[
{ path: "C:/code/zus", source: "scope" },
{ path: "c:\\code\\zus\\", source: "pin" },
{ path: "C:/code/8sync", source: "team" },
{ path: "/srv/app", source: "session" },
{ path: "", source: "x" },
],
["C:/code/8sync"],
);
expect(out).toEqual([
{ path: "/srv/app", name: "app", source: "session" },
{ path: "C:/code/zus", name: "zus", source: "scope" },
]);
expect(mergeKnown([], [])).toEqual([]);
});
});
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.46",
"version": "0.110.47",
"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