From c55cf7ef65c145ab2eb778f0270593e7581078b5 Mon Sep 17 00:00:00 2001 From: 8syncdev Date: Thu, 22 Oct 2026 08:48:05 +0700 Subject: [PATCH] =?UTF-8?q?feat(hq):=20sidebar=20"D=E1=BB=B1=20=C3=A1n=20t?= =?UTF-8?q?r=C3=AAn=20m=C3=A1y"=20=E2=80=94=20every=20repo=20the=20machine?= =?UTF-8?q?=20knows,=20one=20tap=20to=20open=20(v0.110.47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner 2026-10-22: "nên có chỗ hiển thị all dự án trong máy để bấm mở". /api/projects returns `known`: auto scope, team roots, closed projects, pins and the repo of every recorded session, minus added folders, only existing dirs. One tap adds and opens. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 +++ Cargo.lock | 4 +-- Cargo.toml | 2 +- apps/hq-ui/app/api/projects/route.ts | 4 ++- apps/hq-ui/components/hq/nav.tsx | 44 ++++++++++++++++++++++++ apps/hq-ui/components/hq/use-hq.ts | 7 +++- apps/hq-ui/lib/known-projects.ts | 22 ++++++++++++ apps/hq-ui/lib/server/known-projects.ts | 30 ++++++++++++++++ apps/hq-ui/tests/known-projects.test.ts | 22 ++++++++++++ assets/claude/.claude-plugin/plugin.json | 2 +- 10 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 apps/hq-ui/lib/known-projects.ts create mode 100644 apps/hq-ui/lib/server/known-projects.ts create mode 100644 apps/hq-ui/tests/known-projects.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d40a2329..1d224e47 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.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. diff --git a/Cargo.lock b/Cargo.lock index 58d66735..78880d9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -200,7 +200,7 @@ dependencies = [ [[package]] name = "ccc-core" -version = "0.110.46" +version = "0.110.47" dependencies = [ "anyhow", "dirs", @@ -1503,7 +1503,7 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "su-code" -version = "0.110.46" +version = "0.110.47" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index af487023..ed70d756 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 <8sync.dev.1111@gmail.com>"] diff --git a/apps/hq-ui/app/api/projects/route.ts b/apps/hq-ui/app/api/projects/route.ts index ac05afd5..b830b9d3 100644 --- a/apps/hq-ui/app/api/projects/route.ts +++ b/apps/hq-ui/app/api/projects/route.ts @@ -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"; @@ -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) { diff --git a/apps/hq-ui/components/hq/nav.tsx b/apps/hq-ui/components/hq/nav.tsx index df7c8991..1496ca69 100644 --- a/apps/hq-ui/components/hq/nav.tsx +++ b/apps/hq-ui/components/hq/nav.tsx @@ -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 }; @@ -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(null); // The project whose "Hiện lại" is in flight: its "⋯" and "Hiện lại" stay disabled meanwhile. const [hiding, setHiding] = useState(null); @@ -230,6 +233,7 @@ export function Nav({
    {shown.map((g) => group(g, false))}
)} void show(g)} /> + )} @@ -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(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 ( +
+

+ Dự án trên máy · {items.length} +

+
    + {items.map((k) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/apps/hq-ui/components/hq/use-hq.ts b/apps/hq-ui/components/hq/use-hq.ts index 8d46f581..5e510d2e 100644 --- a/apps/hq-ui/components/hq/use-hq.ts +++ b/apps/hq-ui/components/hq/use-hq.ts @@ -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"; @@ -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>(init([])); + // Repos the machine knows but the owner has not added ("Dự án trên máy"). + const [known, setKnown] = useState([]); const [sessions, setSessions] = useState>(init([])); const [channelMode, setChannelMode] = useState(null); const [tasks, setTasks] = useState>(init([])); @@ -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 })); } @@ -271,6 +275,7 @@ export function useHq(token: string) { api, live, projects, + knownProjects: known, sessions, channelMode, tasks, diff --git a/apps/hq-ui/lib/known-projects.ts b/apps/hq-ui/lib/known-projects.ts new file mode 100644 index 00000000..5f42ecc5 --- /dev/null +++ b/apps/hq-ui/lib/known-projects.ts @@ -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(); + 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)); +} diff --git a/apps/hq-ui/lib/server/known-projects.ts b/apps/hq-ui/lib/server/known-projects.ts new file mode 100644 index 00000000..b3d94a8d --- /dev/null +++ b/apps/hq-ui/lib/server/known-projects.ts @@ -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; + } + }); +} diff --git a/apps/hq-ui/tests/known-projects.test.ts b/apps/hq-ui/tests/known-projects.test.ts new file mode 100644 index 00000000..9f1e9a66 --- /dev/null +++ b/apps/hq-ui/tests/known-projects.test.ts @@ -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([]); + }); +}); diff --git a/assets/claude/.claude-plugin/plugin.json b/assets/claude/.claude-plugin/plugin.json index b9696431..6221bb30 100644 --- a/assets/claude/.claude-plugin/plugin.json +++ b/assets/claude/.claude-plugin/plugin.json @@ -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",