Skip to content
Closed
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
130 changes: 128 additions & 2 deletions desktop/src/apps/FilesApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -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 */
/* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -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({
Expand All @@ -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);
Expand Down Expand Up @@ -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 && (
Expand Down Expand Up @@ -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"];

Copy link
Copy Markdown

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 is approved attempts approved -> 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 approved and changes_requested are not connected to each other (both only return to awaiting_review). Use a per-state next mapping instead, e.g.:

const NEXT_STATE: Record<DocReviewState, DocReviewState> = {
  awaiting_review: "approved",
  approved: "awaiting_review",
  changes_requested: "awaiting_review",
};
const next = NEXT_STATE[(current as DocReviewState) ?? "awaiting_review"];

(Note this still cannot reach changes_requested from the badge - you may want a dedicated affordance for that state.)


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Surface rejected writes to the user instead of silently swallowing

The catch {} discards every failure (403/409/401/offline), so when a write is rejected the badge simply does not change with no explanation. Consider calling setError(...) (or re-throwing) so the user knows the action was not applied.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

/* 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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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>
)}
Expand Down Expand Up @@ -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>
Expand All @@ -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>
Expand Down
53 changes: 53 additions & 0 deletions desktop/src/lib/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,27 @@ export type Routine = {
updated_at: number;
};

export type DocReviewState = "awaiting_review" | "approved" | "changes_requested";

export type DocReview = {
id: string;
project_id: string;
doc_path: string;
review_state: DocReviewState;
reviewed_by: string | null;
reviewed_at: number | null;
changes_requested_by: string | null;
changes_requested_at: number | null;
created_at: number;
updated_at: number;
};

export type DocReviewMissing = {
project_id: string;
doc_path: string;
review_state: null;
};

export type TaskContextAncestor = { id: string; title: string; status: string };
export type TaskContextBlocker = { id: string; title: string; status: string };

Expand Down Expand Up @@ -326,11 +347,43 @@ export const projectsApi = {
activity: (pid: string) =>
http<{ items: ProjectActivity[] }>(`/api/projects/${pid}/activity`).then((r) => r.items),

docReviews: {
list: (pid: string, state?: string) =>
http<{ items: DocReview[] }>(
`/api/projects/${encodeURIComponent(pid)}/doc-reviews${state ? `?state=${encodeURIComponent(state)}` : ""}`,
).then((r) => r.items),
get: (pid: string, docPath: string) =>
http<DocReview | DocReviewMissing>(
`/api/projects/${encodeURIComponent(pid)}/doc-review/${encodeURIComponent(docPath)}`,
),
set: (pid: string, docPath: string, state: string) =>
http<DocReview>(
`/api/projects/${encodeURIComponent(pid)}/doc-review/${encodeURIComponent(docPath)}`,
{ method: "PUT", body: JSON.stringify({ state }) },
),
},

subscribeEvents(projectId: string, onEvent: (ev: ProjectEvent) => void): () => void {
const es = new EventSource(`/api/projects/${projectId}/events`);
es.onmessage = (e) => {
try { onEvent(JSON.parse(e.data) as ProjectEvent); } catch { /* heartbeat / malformed — skip */ }
};
return () => es.close();
},

docReview: {
get: (pid: string, docPath: string) =>
http<DocReview | DocReviewMissing>(
`/api/projects/${pid}/doc-review/${encodeURIComponent(docPath)}`,
),
set: (pid: string, docPath: string, state: DocReviewState) =>
http<DocReview>(`/api/projects/${pid}/doc-review/${encodeURIComponent(docPath)}`, {
method: "PUT",
body: JSON.stringify({ state }),
}),
list: (pid: string, state?: string) =>
http<{ items: DocReview[] }>(
`/api/projects/${pid}/doc-reviews${state ? `?state=${state}` : ""}`,
).then((r) => r.items),
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
Loading
Loading