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
72 changes: 70 additions & 2 deletions src/components/layout/EditorPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { ConfirmDialog } from "@/components/layout/ConfirmDialog";
import { toast } from "@/components/ui";
Expand All @@ -7,12 +7,13 @@ import { workspaceFsAdapter } from "@/lib/fs";
import type { WorkspaceCreateEntryResult, WorkspaceDeleteEntryResult, WorkspaceDirectoryEntry } from "@/lib/fs/fs.types";
import { parseUnifiedDiffToBuffers } from "@/lib/source-control-diff";
import { hasSourceControlStagedChanges, type SourceControlStatusItem } from "@/lib/source-control-status";
import { resolveWorkspaceTodoStatus } from "@/lib/workspace-information";
import { useAppStore } from "@/store/app.store";
import type { SectionId } from "@/components/layout/settings-dialog.schema";
import { RightRailPanelShell } from "./RightRailPanelShell";
import { WorkspaceScriptsPanel } from "./WorkspaceScriptsPanel";
import { WorkspaceSkillsPanel } from "./WorkspaceSkillsPanel";
import { WorkspaceChangesPanel } from "./WorkspaceChangesPanel";
import { WorkspaceChangesPanel, type WorkspaceChecksViewModel } from "./WorkspaceChangesPanel";
import { WorkspaceExplorerPanel } from "./WorkspaceExplorerPanel";
import { WorkspaceInformationPanel } from "./WorkspaceInformationPanel";
import { WorkspaceLensPanel } from "./WorkspaceLensPanel";
Expand Down Expand Up @@ -110,6 +111,11 @@ export function EditorPanel(props: EditorPanelProps) {
refreshProjectFiles,
closeEditorTab,
updateSettings,
requestVerificationFix,
workspaceTodos,
workspacePrInfo,
isDefaultWorkspace,
fetchWorkspacePrStatus,
] = useAppStore(useShallow((state) => [
state.activeWorkspaceId,
state.hasHydratedWorkspaces,
Expand All @@ -126,8 +132,51 @@ export function EditorPanel(props: EditorPanelProps) {
state.refreshProjectFiles,
state.closeEditorTab,
state.updateSettings,
state.requestVerificationFix,
state.workspaceInformation.todos,
state.workspacePrInfoById[state.activeWorkspaceId] ?? null,
Boolean(state.workspaceDefaultById[state.activeWorkspaceId]),
state.fetchWorkspacePrStatus,
] as const));

const handleFixVerificationWithAgent = useCallback(
async (args?: { scriptId?: string }) => {
const result = await requestVerificationFix({
workspaceId: activeWorkspaceId,
scriptId: args?.scriptId,
});
if (result.status === "blocked") {
toast.error("Couldn't forward the failures to the agent");
return;
}
toast.success(
result.status === "queued"
? "Queued fix for the next turn"
: "Sent failing checks to the agent",
);
},
[requestVerificationFix, activeWorkspaceId],
);

const checks = useMemo<WorkspaceChecksViewModel>(() => {
const openTodos = workspaceTodos.filter(
(todo) => resolveWorkspaceTodoStatus(todo) !== "completed",
);
return {
prStatus: workspacePrInfo?.derived ?? "no_pr",
pr: workspacePrInfo?.pr
? {
number: workspacePrInfo.pr.number,
title: workspacePrInfo.pr.title,
url: workspacePrInfo.pr.url,
}
: null,
openTodoCount: openTodos.length,
totalTodoCount: workspaceTodos.length,
openTodos: openTodos.map((todo) => todo.text),
};
}, [workspaceTodos, workspacePrInfo]);

const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [explorerDirectoryStateByPath, setExplorerDirectoryStateByPath] = useState<Record<string, ExplorerDirectoryState>>({});
const [explorerError, setExplorerError] = useState("");
Expand Down Expand Up @@ -351,6 +400,21 @@ export function EditorPanel(props: EditorPanelProps) {
}
}, [rightTab, sidebarOverlayVisible, workspaceCwd]);

// Keep the Checks tab's PR readiness fresh. Gated on the changes tab so it
// never double-fetches alongside the Information panel (mutually exclusive
// right-rail tabs).
useEffect(() => {
if (rightTab !== "changes" || !sidebarOverlayVisible) return;
if (!activeWorkspaceId || isDefaultWorkspace) return;
void fetchWorkspacePrStatus({ workspaceId: activeWorkspaceId });
}, [
rightTab,
sidebarOverlayVisible,
activeWorkspaceId,
isDefaultWorkspace,
fetchWorkspacePrStatus,
]);

const loadScmStatusRef = useRef(loadScmStatus);
loadScmStatusRef.current = loadScmStatus;
const isScmBusyRef = useRef(isScmBusy);
Expand Down Expand Up @@ -948,6 +1012,10 @@ export function EditorPanel(props: EditorPanelProps) {
onAutoRefreshSecondsChange={(seconds) => updateSettings({ patch: { scmAutoRefreshSeconds: seconds } })}
verification={turnVerification ?? null}
intentCompliance={turnIntentCompliance ?? null}
checks={checks}
onFixVerificationWithAgent={(args) =>
void handleFixVerificationWithAgent(args)
}
/>
) : null}

Expand Down
112 changes: 103 additions & 9 deletions src/components/layout/FleetView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ import {
import {
classifyTaskStatus,
compareFleetTaskStatus,
deriveFleetLifecycleStatus,
FLEET_LIFECYCLE_LABEL,
groupFleetWorkspacesByLane,
hasFleetTaskAttentionStatus,
type FleetLifecycleStatus,
type FleetTaskStatus,
} from "@/lib/fleet/task-status";
import {
Expand Down Expand Up @@ -333,6 +337,10 @@ function FleetWorkspaceSection(args: {
workspaceId: string,
target: FleetAttentionTarget | null,
) => void;
onLifecycleChange: (
workspaceId: string,
status: FleetLifecycleStatus | null,
) => void;
}) {
const [
activeWorkspaceId,
Expand Down Expand Up @@ -520,6 +528,23 @@ function FleetWorkspaceSection(args: {
return () => args.onAttentionTargetChange(args.workspace.id, null);
}, [args.onAttentionTargetChange, args.workspace.id, firstAttentionTarget]);

const lifecycle = useMemo<FleetLifecycleStatus>(
() =>
deriveFleetLifecycleStatus({
prStatus,
hasRunningTask: rows.some(
(row) => row.status !== "unknown" && row.status !== "idle",
),
hasRecentActivity: rows.some((row) => row.messageCount > 0),
}),
[prStatus, rows],
);

useEffect(() => {
args.onLifecycleChange(args.workspace.id, lifecycle);
return () => args.onLifecycleChange(args.workspace.id, null);
}, [args.onLifecycleChange, args.workspace.id, lifecycle]);

return (
<section className="border-b border-border/70">
<div className="flex min-h-12 items-center justify-between gap-3 bg-card/60 px-4 py-2">
Expand Down Expand Up @@ -585,12 +610,38 @@ function FleetWorkspaceSection(args: {

const MemoizedFleetWorkspaceSection = memo(FleetWorkspaceSection);

const FLEET_LIFECYCLE_DOT: Record<FleetLifecycleStatus, string> = {
"in-progress": "bg-primary",
"in-review": "bg-warning",
backlog: "bg-muted-foreground/50",
done: "bg-success",
};

function FleetLaneHeader(args: { lane: FleetLifecycleStatus; count: number }) {
return (
<div className="flex items-center gap-2 border-t border-border/60 bg-background/80 px-4 py-1.5">
<span
className={cn("size-1.5 rounded-full", FLEET_LIFECYCLE_DOT[args.lane])}
/>
<span className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{FLEET_LIFECYCLE_LABEL[args.lane]}
</span>
<span className="text-[11px] tabular-nums text-muted-foreground">
{args.count}
</span>
</div>
);
}

export function FleetView() {
const projects = useFleetProjects();
const focusTaskAttention = useAppStore((state) => state.focusTaskAttention);
const closeFleetView = useAppStore((state) => state.closeFleetView);
const [attentionTargetsByWorkspaceId, setAttentionTargetsByWorkspaceId] =
useState<Record<string, FleetAttentionTarget>>({});
const [lifecycleByWorkspaceId, setLifecycleByWorkspaceId] = useState<
Record<string, FleetLifecycleStatus>
>({});

useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
Expand Down Expand Up @@ -626,6 +677,24 @@ export function FleetView() {
[],
);

const handleLifecycleChange = useCallback(
(workspaceId: string, status: FleetLifecycleStatus | null) => {
setLifecycleByWorkspaceId((current) => {
if ((current[workspaceId] ?? null) === status) {
return current;
}
const next = { ...current };
if (status) {
next[workspaceId] = status;
} else {
delete next[workspaceId];
}
return next;
});
},
[],
);

const nextAttentionTarget = useMemo(() => {
for (const project of projects) {
for (const workspace of project.workspaces) {
Expand Down Expand Up @@ -729,15 +798,40 @@ export function FleetView() {
No workspaces
</div>
) : (
project.workspaces.map((workspace) => (
<MemoizedFleetWorkspaceSection
key={workspace.id}
projectPath={project.projectPath}
workspace={workspace}
onOpenTask={handleOpenTask}
onAttentionTargetChange={handleAttentionTargetChange}
/>
))
(() => {
const laneGroups = groupFleetWorkspacesByLane({
workspaces: project.workspaces,
lifecycleByWorkspaceId,
});
// Only label lanes once a project actually spans more than
// one — a single-lane project stays as clean as before.
const showLanes = laneGroups.length > 1;
const elements: ReactNode[] = [];
for (const group of laneGroups) {
if (showLanes) {
elements.push(
<FleetLaneHeader
key={`lane:${group.lane}`}
lane={group.lane}
count={group.workspaces.length}
/>,
);
}
for (const workspace of group.workspaces) {
elements.push(
<MemoizedFleetWorkspaceSection
key={workspace.id}
projectPath={project.projectPath}
workspace={workspace}
onOpenTask={handleOpenTask}
onAttentionTargetChange={handleAttentionTargetChange}
onLifecycleChange={handleLifecycleChange}
/>,
);
}
}
return elements;
})()
)}
</section>
))}
Expand Down
Loading
Loading