-
-
Notifications
You must be signed in to change notification settings - Fork 34
feat(projects): doc-review stamp store + routes (#1802 slice 3) #1834
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,6 +34,7 @@ import { ContextMenu, type MenuItem } from "@/components/ContextMenu"; | |
| import { MobileSplitView } from "@/components/mobile/MobileSplitView"; | ||
| import { useIsMobile } from "@/hooks/use-is-mobile"; | ||
| import { resolveAgentEmoji } from "@/lib/agent-emoji"; | ||
| import { projectsApi, type DocReviewState } from "@/lib/projects"; | ||
| import { useDragSource } from "@/shell/dnd/use-drag-source"; | ||
|
|
||
| /* ------------------------------------------------------------------ */ | ||
|
|
@@ -297,6 +298,50 @@ function getFileIcon(name: string, isDir: boolean) { | |
| return EXT_ICONS[ext] ?? File; | ||
| } | ||
|
|
||
| const REVIEW_BADGE_STYLES: Record<string, string> = { | ||
| approved: "bg-emerald-500/15 text-emerald-400 border-emerald-500/20", | ||
| changes_requested: "bg-amber-500/15 text-amber-400 border-amber-500/20", | ||
| awaiting_review: "bg-zinc-500/10 text-zinc-400 border-zinc-500/15", | ||
| }; | ||
|
|
||
| const REVIEW_BADGE_LABEL: Record<string, string> = { | ||
| approved: "Approved", | ||
| changes_requested: "Changes requested", | ||
| awaiting_review: "Awaiting review", | ||
| }; | ||
|
|
||
| function ReviewBadge({ | ||
| state, | ||
| onClick, | ||
| }: { | ||
| state: string | null; | ||
| onClick?: () => void; | ||
| }) { | ||
| if (!state) return null; | ||
| const style = REVIEW_BADGE_STYLES[state] ?? REVIEW_BADGE_STYLES.awaiting_review; | ||
| const label = REVIEW_BADGE_LABEL[state] ?? state; | ||
| if (onClick) { | ||
| return ( | ||
| <button | ||
| type="button" | ||
| onClick={onClick} | ||
| title={`Review state: ${label} (click to change)`} | ||
| className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded-full border ${style} hover:brightness-125 transition`} | ||
| > | ||
| {label} | ||
| </button> | ||
| ); | ||
| } | ||
| return ( | ||
| <span | ||
| className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded-full border ${style}`} | ||
| title={`Review state: ${label}`} | ||
| > | ||
| {label} | ||
| </span> | ||
| ); | ||
| } | ||
|
|
||
| /* ------------------------------------------------------------------ */ | ||
| /* API helpers */ | ||
| /* ------------------------------------------------------------------ */ | ||
|
|
@@ -329,6 +374,8 @@ interface FileRowProps { | |
| setDeleteConfirm: (path: string | null) => void; | ||
| onContextMenu: (e: React.MouseEvent, f: FileEntry) => void; | ||
| isCut: boolean; | ||
| reviewState: string | null; | ||
| onDocReviewClick?: () => void; | ||
| } | ||
|
|
||
| function FileRow({ | ||
|
|
@@ -342,6 +389,8 @@ function FileRow({ | |
| setDeleteConfirm, | ||
| onContextMenu, | ||
| isCut, | ||
| reviewState, | ||
| onDocReviewClick, | ||
| }: FileRowProps) { | ||
| const Icon = getFileIcon(f.name, f.is_dir); | ||
| const relPath = f.path || (currentPath ? `${currentPath}/${f.name}` : f.name); | ||
|
|
@@ -410,6 +459,11 @@ function FileRow({ | |
| <td className="px-3 py-2 text-shell-text-tertiary"> | ||
| {formatDate(f.modified)} | ||
| </td> | ||
| <td className="px-3 py-2"> | ||
| {!f.is_dir && isProjectLocation(location) && ( | ||
| <ReviewBadge state={reviewState} onClick={onDocReviewClick} /> | ||
| )} | ||
| </td> | ||
| <td className="px-3 py-2"> | ||
| <div className="flex items-center gap-1"> | ||
| {!f.is_dir && isWritable && ( | ||
|
|
@@ -502,9 +556,50 @@ export function FilesApp({ | |
| const [workspaceTrashLoading, setWorkspaceTrashLoading] = useState(false); | ||
| const [workspaceTrashError, setWorkspaceTrashError] = useState<string | null>(null); | ||
|
|
||
| // Doc-review stamp state (project: locations only) | ||
| const [reviewStates, setReviewStates] = useState<Record<string, string | null>>({}); | ||
|
|
||
| const fileInputRef = useRef<HTMLInputElement>(null); | ||
| const dragCounter = useRef(0); | ||
|
|
||
| const fetchReviewStates = useCallback(async (loc: string) => { | ||
| if (!isProjectLocation(loc)) { | ||
| setReviewStates({}); | ||
| return; | ||
| } | ||
| const pid = projectSlug(loc); | ||
| try { | ||
| const items = await projectsApi.docReviews.list(pid); | ||
| const map: Record<string, string | null> = {}; | ||
| for (const item of items) { | ||
| if (item.review_state) map[item.doc_path] = item.review_state; | ||
| } | ||
| setReviewStates(map); | ||
| } catch { | ||
| setReviewStates({}); | ||
| } | ||
| }, []); | ||
|
|
||
| const cycleDocReview = useCallback( | ||
| async (docPath: string, current: string | null) => { | ||
| if (!isProjectLocation(location)) return; | ||
| const pid = projectSlug(location); | ||
| const order: DocReviewState[] = ["awaiting_review", "approved", "changes_requested"]; | ||
| const idx = current ? order.indexOf(current as DocReviewState) : 0; | ||
| const next = order[(idx + 1) % order.length]!; | ||
| try { | ||
| await projectsApi.docReviews.set(pid, docPath, next); | ||
| setReviewStates((prev) => { | ||
| const nextMap: Record<string, string | null> = { ...prev, [docPath]: next }; | ||
| return nextMap; | ||
| }); | ||
| } catch { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION: Surface rejected writes to the user instead of silently swallowing The Reply with |
||
| /* write rejected (forbidden / offline): keep the current stamp */ | ||
| } | ||
| }, | ||
| [location], | ||
| ); | ||
|
|
||
| // Workspace locations (user + per-agent + project) allow mutations and the stats | ||
| // endpoint. Shared folders and the recycle bin are read-only here. | ||
| const isWritable = location === "workspace" || isAgentLocation(location) || isProjectLocation(location); | ||
|
|
@@ -611,6 +706,11 @@ export function FilesApp({ | |
| .catch(() => setStats(null)); | ||
| }, [location]); | ||
|
|
||
| // Refresh doc-review badges whenever we switch project locations. | ||
| useEffect(() => { | ||
| fetchReviewStates(location); | ||
| }, [location, fetchReviewStates]); | ||
|
|
||
| /* ---- Recycle bin ---- */ | ||
| const fetchRecycle = useCallback(async () => { | ||
| setRecycleLoading(true); | ||
|
|
@@ -722,9 +822,10 @@ export function FilesApp({ | |
| await apiFetch("/api/workspace/trash", { method: "DELETE" }); | ||
| fetchWorkspaceTrash(); | ||
| } catch (e: unknown) { | ||
| setWorkspaceTrashError(e instanceof Error ? e.message : "Empty trash failed"); | ||
| const msg = e instanceof Error ? e.message : "Empty trash failed"; | ||
| setError(msg); | ||
| } | ||
| }, [fetchWorkspaceTrash, workspaceTrashItems.length]); | ||
| }, [workspaceTrashItems.length, fetchWorkspaceTrash]); | ||
|
|
||
| /* ---- Actions ---- */ | ||
| const handleNewFolder = useCallback(async () => { | ||
|
|
@@ -1602,6 +1703,20 @@ export function FilesApp({ | |
| <span className="text-xs truncate w-full leading-tight" title={f.name}> | ||
| {f.name} | ||
| </span> | ||
| {!f.is_dir && isProjectLocation(location) && ( | ||
| <ReviewBadge | ||
| state={reviewStates[f.path || f.name] ?? null} | ||
| onClick={ | ||
| isProjectLocation(location) | ||
| ? () => | ||
| cycleDocReview( | ||
| f.path || f.name, | ||
| reviewStates[f.path || f.name] ?? null, | ||
| ) | ||
| : undefined | ||
| } | ||
| /> | ||
| )} | ||
| {!f.is_dir && ( | ||
| <span className="text-[10px] text-shell-text-tertiary">{formatSize(f.size)}</span> | ||
| )} | ||
|
|
@@ -1656,6 +1771,7 @@ export function FilesApp({ | |
| <th className="px-3 py-2 font-medium">Name</th> | ||
| <th className="px-3 py-2 font-medium w-24">Size</th> | ||
| <th className="px-3 py-2 font-medium w-32">Modified</th> | ||
| <th className="px-3 py-2 font-medium w-28">Review</th> | ||
| <th className="px-3 py-2 font-medium w-16">Actions</th> | ||
| </tr> | ||
| </thead> | ||
|
|
@@ -1677,6 +1793,16 @@ export function FilesApp({ | |
| setMenu({ x: e.clientX, y: e.clientY, file }); | ||
| }} | ||
| isCut={clipboard?.mode === "cut" && clipboard.location === location && clipboard.path === f.path} | ||
| reviewState={ | ||
| !f.is_dir && isProjectLocation(location) | ||
| ? reviewStates[f.path || f.name] ?? null | ||
| : null | ||
| } | ||
| onDocReviewClick={ | ||
| !f.is_dir && isProjectLocation(location) | ||
| ? () => cycleDocReview(f.path || f.name, reviewStates[f.path || f.name] ?? null) | ||
| : undefined | ||
| } | ||
| /> | ||
| ))} | ||
| </tbody> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WARNING: Doc-review cycle includes an invalid transition (
approved -> changes_requested)The backend state machine (VALID_TRANSITIONS in tinyagentos/projects/doc_review_store.py) only permits
approved -> awaiting_review. This cycle order is[awaiting_review, approved, changes_requested], so clicking the badge while a doc isapprovedattemptsapproved -> changes_requested, which the route rejects with HTTP 409. That error is swallowed by the catch block below, so the badge silently stays "Approved" and appears stuck.No linear 3-state cycle is possible because
approvedandchanges_requestedare not connected to each other (both only return toawaiting_review). Use a per-state next mapping instead, e.g.:(Note this still cannot reach
changes_requestedfrom the badge - you may want a dedicated affordance for that state.)Reply with
@kilocode-bot fix itto have Kilo Code address this issue.