feat: enhance team task management with reviewer resolution and UI improvements

- Added a new method in TeamDataService to extract the reviewer from task history events as a fallback when not present in the kanban state.
- Updated TeamDetailView to support adding multiple members at once, improving the member addition process.
- Enhanced TaskCommentInput to display a more user-friendly reply interface with expandable quotes and member badges.
- Improved TaskDetailDialog to show the reviewer information when a task is approved, enhancing task visibility.
- Introduced loading state handling in ChangeReviewDialog and ContinuousScrollView for better user experience during file loading.
This commit is contained in:
iliya 2026-03-15 12:24:51 +02:00
parent cbd09199b3
commit 217eafe6a2
10 changed files with 418 additions and 240 deletions

View file

@ -134,14 +134,33 @@ export class TeamDataService {
kanbanTaskState?: KanbanState['tasks'][string]
): TeamTaskWithKanban {
const reviewState = this.resolveTaskReviewState(task);
const reviewer = kanbanTaskState?.reviewer ?? this.resolveReviewerFromHistory(task) ?? null;
return {
...task,
reviewState,
kanbanColumn: getKanbanColumnFromReviewState(reviewState),
reviewer: kanbanTaskState?.reviewer ?? null,
reviewer,
};
}
/**
* Extract reviewer name from task history events as a fallback
* when kanban state doesn't have it (e.g. review done via MCP agent-teams).
*/
private resolveReviewerFromHistory(task: TeamTask): string | null {
if (!task.historyEvents?.length) return null;
for (let i = task.historyEvents.length - 1; i >= 0; i--) {
const event = task.historyEvents[i];
if (event.type === 'review_approved' && event.actor) {
return event.actor;
}
if (event.type === 'review_requested' && event.reviewer) {
return event.reviewer;
}
}
return null;
}
async listTeams(): Promise<TeamSummary[]> {
return this.configReader.listTeams();
}

View file

@ -55,6 +55,7 @@ import {
import { useShallow } from 'zustand/react/shallow';
import { AddMemberDialog } from './dialogs/AddMemberDialog';
import type { AddMemberEntry } from './dialogs/AddMemberDialog';
import { CreateTaskDialog } from './dialogs/CreateTaskDialog';
import { EditTeamDialog } from './dialogs/EditTeamDialog';
import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog';
@ -1403,6 +1404,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
badge={filteredTasks.length}
defaultOpen
forceOpen={kanbanSearch.trim().length > 0}
contentClassName="overflow-x-visible"
action={
<Button
variant="ghost"
@ -1768,15 +1770,20 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
open={addMemberDialogOpen}
teamName={teamName}
existingNames={data.members.map((m) => m.name)}
existingMembers={data.members}
projectPath={data.config.projectPath}
adding={addingMemberLoading}
onClose={() => setAddMemberDialogOpen(false)}
onAdd={(name, role, workflow) => {
onAdd={(entries: AddMemberEntry[]) => {
setAddingMemberLoading(true);
void (async () => {
try {
await addMember(teamName, { name, role, workflow });
for (const entry of entries) {
await addMember(teamName, {
name: entry.name,
role: entry.role,
workflow: entry.workflow,
});
}
setAddMemberDialogOpen(false);
} catch {
// error shown via store

View file

@ -1,6 +1,12 @@
import { useCallback, useMemo, useState } from 'react';
import { RoleSelect } from '@renderer/components/team/RoleSelect';
import {
buildMembersFromDrafts,
createMemberDraft,
MembersEditorSection,
validateMemberNameInline,
} from '@renderer/components/team/members/MembersEditorSection';
import { getNextSuggestedMemberName } from '@renderer/components/team/members/memberNameSets';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
@ -10,30 +16,33 @@ import {
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { CUSTOM_ROLE, NO_ROLE } from '@renderer/constants/teamRoles';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { Loader2 } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember } from '@shared/types';
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
const NAME_REGEX = /^[a-z0-9][a-z0-9-]*$/;
export interface AddMemberEntry {
name: string;
role?: string;
workflow?: string;
}
interface AddMemberDialogProps {
open: boolean;
teamName: string;
existingNames: string[];
onClose: () => void;
onAdd: (name: string, role?: string, workflow?: string) => void;
/** Called with the list of new members to add. */
onAdd: (members: AddMemberEntry[]) => void;
adding?: boolean;
/** Project path for @file mentions in workflow field. */
projectPath?: string | null;
/** Existing team members for @mention suggestions. */
existingMembers?: ResolvedTeamMember[];
}
const DIALOG_WIDTH = 'w-[720px]';
function buildInitialDrafts(existingNames: string[]): MemberDraft[] {
const suggestedName = getNextSuggestedMemberName(existingNames);
return [createMemberDraft({ name: suggestedName })];
}
export const AddMemberDialog = ({
@ -44,159 +53,131 @@ export const AddMemberDialog = ({
onAdd,
adding,
projectPath,
existingMembers = [],
}: AddMemberDialogProps): React.JSX.Element => {
const [name, setName] = useState('');
const [roleSelect, setRoleSelect] = useState<string>(NO_ROLE);
const [customRole, setCustomRole] = useState('');
const [members, setMembers] = useState<MemberDraft[]>(() => buildInitialDrafts(existingNames));
const [error, setError] = useState<string | null>(null);
const draftKey = `addMember:${teamName}:workflow`;
const workflowDraft = useDraftPersistence({
key: draftKey,
enabled: open,
});
// Combine existing names + names already in the draft list for duplicate validation
const allNames = useMemo(() => {
const draftNames = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
return [...existingNames.map((n) => n.toLowerCase()), ...draftNames];
}, [existingNames, members]);
// Pre-warm file list cache for @file mentions
useFileListCacheWarmer(open && projectPath ? projectPath : null);
const validateName = useCallback(
(name: string): string | null => {
const trimmed = name.trim().toLowerCase();
if (!trimmed) return null;
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>
existingMembers
.filter((m) => !m.removedAt)
.map((m) => ({
id: m.name,
name: m.name,
subtitle: m.role ?? undefined,
color: m.color,
})),
[existingMembers]
const inlineError = validateMemberNameInline(name);
if (inlineError) return inlineError;
if (trimmed === 'user' || trimmed === 'team-lead') return `Name "${trimmed}" is reserved`;
// Check against existing team members
if (existingNames.some((n) => n.toLowerCase() === trimmed)) return 'Name is already taken';
// Check for duplicates within the draft list
const draftOccurrences = members.filter(
(m) => m.name.trim().toLowerCase() === trimmed
).length;
if (draftOccurrences > 1) return 'Duplicate name in the list';
return null;
},
[existingNames, members]
);
const effectiveRole =
roleSelect === CUSTOM_ROLE
? customRole.trim()
: roleSelect === NO_ROLE
? undefined
: roleSelect;
const validate = (): string | null => {
const trimmed = name.trim().toLowerCase();
if (!trimmed) return 'Name is required';
if (trimmed.length < 2) return 'Name must be at least 2 characters';
if (trimmed.length > 30) return 'Name must be at most 30 characters';
if (!NAME_REGEX.test(trimmed))
return 'Name must be lowercase alphanumeric with hyphens (e.g. alice, dev-1)';
if (trimmed === 'user') return 'Name "user" is reserved';
if (trimmed === 'team-lead') return 'Name "team-lead" is reserved';
if (existingNames.some((n) => n.toLowerCase() === trimmed)) return 'Name is already taken';
return null;
};
const hasValidMembers = useMemo(() => {
const valid = members.filter((m) => {
const name = m.name.trim();
return name.length > 0 && !validateName(name);
});
return valid.length > 0;
}, [members, validateName]);
const handleSubmit = (): void => {
const err = validate();
if (err) {
setError(err);
const built = buildMembersFromDrafts(members);
// Validate all entries
const invalid = built.find((m) => validateName(m.name));
if (invalid) {
setError(validateName(invalid.name));
return;
}
if (built.length === 0) {
setError('Add at least one member');
return;
}
setError(null);
const wf = workflowDraft.value.trim() || undefined;
onAdd(name.trim().toLowerCase(), effectiveRole, wf);
// Reset form fields after successful submission
setName('');
setRoleSelect(NO_ROLE);
setCustomRole('');
workflowDraft.clearDraft();
onAdd(
built.map((m) => ({
name: m.name,
role: m.role,
workflow: m.workflow,
}))
);
};
const handleOpenChange = (nextOpen: boolean): void => {
if (!nextOpen) {
setName('');
setRoleSelect(NO_ROLE);
setCustomRole('');
workflowDraft.setValue('');
workflowDraft.clearDraft();
setMembers(buildInitialDrafts(existingNames));
setError(null);
onClose();
}
};
const handleWorkflowChange = useCallback(
(v: string) => {
workflowDraft.setValue(v);
},
[workflowDraft]
);
// Re-initialize drafts when the dialog opens with fresh suggested name
// (existingNames may have changed since last close)
const handleAfterOpen = useMemo(() => {
if (open) {
return () => {
setMembers((prev) => {
// Only reset if previous state looks like a leftover from last session
const allEmpty = prev.every((m) => !m.name.trim());
if (prev.length === 0 || allEmpty) {
return buildInitialDrafts(existingNames);
}
return prev;
});
};
}
return undefined;
}, [open, existingNames]);
// Trigger on mount/open
useMemo(() => handleAfterOpen?.(), [handleAfterOpen]);
const memberCount = members.filter((m) => m.name.trim() && !validateName(m.name)).length;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-3xl">
<DialogContent className={`${DIALOG_WIDTH} max-w-[90vw]`}>
<DialogHeader>
<DialogTitle>Add Member</DialogTitle>
<DialogDescription>Add a new member to {teamName}</DialogDescription>
<DialogTitle>Add Members</DialogTitle>
<DialogDescription>Add new members to {teamName}</DialogDescription>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Name</Label>
<Input
placeholder="e.g. alice, dev-1"
value={name}
onChange={(e) => {
setName(e.target.value);
setError(null);
}}
autoFocus
/>
{error && <p className="text-xs text-red-400">{error}</p>}
</div>
<div className="max-h-[60vh] overflow-y-auto py-2">
<MembersEditorSection
members={members}
onChange={setMembers}
fieldError={error ?? undefined}
validateMemberName={validateName}
showWorkflow
showJsonEditor={false}
draftKeyPrefix={`addMember:${teamName}`}
projectPath={projectPath}
/>
</div>
<div className="space-y-2">
<Label className="label-optional">Role (optional)</Label>
<RoleSelect
value={roleSelect}
onValueChange={setRoleSelect}
customRole={customRole}
onCustomRoleChange={setCustomRole}
/>
</div>
<div className="space-y-2">
<Label className="label-optional">Workflow (optional)</Label>
<MentionableTextarea
className="text-xs"
minRows={3}
maxRows={8}
value={workflowDraft.value}
onValueChange={handleWorkflowChange}
suggestions={mentionSuggestions}
projectPath={projectPath ?? undefined}
placeholder="How this agent should behave, what tasks it handles..."
footerRight={
workflowDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
) : null
}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose} disabled={adding}>
Cancel
</Button>
<Button type="submit" disabled={adding || !name.trim()}>
{adding ? <Loader2 className="mr-1.5 size-4 animate-spin" /> : null}
Add
</Button>
</DialogFooter>
</form>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose} disabled={adding}>
Cancel
</Button>
<Button type="button" disabled={adding || !hasValidMembers} onClick={handleSubmit}>
{adding ? <Loader2 className="mr-1.5 size-4 animate-spin" /> : null}
{memberCount > 1 ? `Add ${memberCount} members` : 'Add member'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);

View file

@ -2,7 +2,6 @@ import { useCallback, useMemo, useRef, useState } from 'react';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
@ -19,7 +18,9 @@ import {
import { MAX_TEXT_LENGTH } from '@shared/constants';
import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types';
@ -27,6 +28,7 @@ import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types
const MAX_ATTACHMENTS = 5;
const MAX_FILE_SIZE = 20 * 1024 * 1024;
const ACCEPTED_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
const LONG_QUOTE_THRESHOLD = 200;
interface TaskCommentInputProps {
teamName: string;
@ -64,6 +66,7 @@ export const TaskCommentInput = ({
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
const [attachError, setAttachError] = useState<string | null>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [quoteExpanded, setQuoteExpanded] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const mentionSuggestions = useMemo<MentionSuggestion[]>(
@ -195,31 +198,17 @@ export const TaskCommentInput = ({
return (
<div>
{replyTo ? (
<div className="mb-2 flex items-start gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-2">
<div className="min-w-0 flex-1">
<div className="mb-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
Replying to{' '}
<span
className="font-semibold"
style={{
color: (() => {
const rc = colorMap.get(replyTo.author);
return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)';
})(),
}}
>
@{replyTo.author}
</span>
</div>
<div className="line-clamp-3 text-[11px] text-[var(--color-text-muted)]">
{replyTo.text}
</div>
</div>
<div className="relative overflow-hidden rounded-t-md border border-b-0 border-blue-400/30 bg-blue-100/80 py-2 pl-3 pr-2 dark:border-blue-500/20 dark:bg-blue-950/20">
{/* Decorative quotation mark */}
<span className="pointer-events-none absolute -right-1 top-1/2 -translate-y-1/2 select-none font-serif text-[64px] leading-none text-blue-500/[0.08] dark:text-blue-400/[0.08]">
&ldquo;
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
className="absolute right-1.5 top-1.5 z-10 rounded p-0.5 text-blue-400/60 hover:text-blue-600 dark:text-blue-300/40 dark:hover:text-blue-200"
onClick={onClearReply}
>
<X size={12} />
@ -227,6 +216,29 @@ export const TaskCommentInput = ({
</TooltipTrigger>
<TooltipContent side="left">Cancel reply</TooltipContent>
</Tooltip>
<div className="mb-1 flex items-center gap-1.5">
<span className="text-[10px] text-blue-600/70 dark:text-blue-300/60">Replying to</span>
<MemberBadge name={replyTo.author} color={colorMap.get(replyTo.author)} size="sm" />
</div>
<div
className={`pr-5 opacity-60 dark:opacity-50 ${quoteExpanded ? '' : 'max-h-[3.75rem] overflow-hidden'}`}
>
<MarkdownViewer
content={replyTo.text}
bare
maxHeight={quoteExpanded ? 'max-h-48' : 'max-h-[3.75rem]'}
/>
</div>
{replyTo.text.length > LONG_QUOTE_THRESHOLD ? (
<button
type="button"
className="mt-0.5 text-[10px] text-blue-500 hover:text-blue-700 dark:text-blue-400/60 dark:hover:text-blue-300"
onClick={() => setQuoteExpanded((v) => !v)}
>
{quoteExpanded ? 'less' : 'more'}
</button>
) : null}
</div>
) : null}
@ -286,6 +298,7 @@ export const TaskCommentInput = ({
/>
<MentionableTextarea
id={`task-comment-${taskId}`}
className={replyTo ? 'rounded-t-none' : undefined}
placeholder="Add a comment... (Enter to send)"
value={draft.value}
onValueChange={draft.setValue}

View file

@ -504,6 +504,13 @@ export const TaskDetailDialog = ({
>
{statusLabel}
</span>
{currentTask.reviewState === 'approved' && currentTask.reviewer ? (
<MemberBadge
name={currentTask.reviewer}
color={colorMap.get(currentTask.reviewer)}
size="sm"
/>
) : null}
{currentTask.reviewState === 'needsFix' ? (
<span
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
@ -655,20 +662,24 @@ export const TaskDetailDialog = ({
<span className="text-xs text-[var(--color-text-muted)]">Links</span>
{relatedIds.map((id) => {
const depTask = taskMap.get(id);
const label = depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`;
return (
<button
key={`related:${currentTask.id}:${id}`}
type="button"
className="inline-flex items-center rounded bg-purple-500/15 px-1.5 py-0.5 text-[10px] font-medium text-purple-300 transition-colors hover:bg-purple-500/25"
title={
depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`
}
onClick={() => handleDependencyClick(id)}
>
{depTask ? formatTaskDisplayLabel(depTask) : `#${deriveTaskDisplayId(id)}`}
</button>
<Tooltip key={`related:${currentTask.id}:${id}`}>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center rounded bg-purple-500/15 px-1.5 py-0.5 text-[10px] font-medium text-purple-300 transition-colors hover:bg-purple-500/25"
onClick={() => handleDependencyClick(id)}
>
{depTask
? formatTaskDisplayLabel(depTask)
: `#${deriveTaskDisplayId(id)}`}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
);
})}
</div>
@ -679,20 +690,24 @@ export const TaskDetailDialog = ({
<span className="text-xs text-[var(--color-text-muted)]">Linked from</span>
{relatedByIds.map((id) => {
const depTask = taskMap.get(id);
const label = depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`;
return (
<button
key={`related-by:${currentTask.id}:${id}`}
type="button"
className="inline-flex items-center rounded bg-fuchsia-500/15 px-1.5 py-0.5 text-[10px] font-medium text-fuchsia-300 transition-colors hover:bg-fuchsia-500/25"
title={
depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`
}
onClick={() => handleDependencyClick(id)}
>
{depTask ? formatTaskDisplayLabel(depTask) : `#${deriveTaskDisplayId(id)}`}
</button>
<Tooltip key={`related-by:${currentTask.id}:${id}`}>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center rounded bg-fuchsia-500/15 px-1.5 py-0.5 text-[10px] font-medium text-fuchsia-300 transition-colors hover:bg-fuchsia-500/25"
onClick={() => handleDependencyClick(id)}
>
{depTask
? formatTaskDisplayLabel(depTask)
: `#${deriveTaskDisplayId(id)}`}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
);
})}
</div>
@ -991,26 +1006,28 @@ export const TaskDetailDialog = ({
{blockedByIds.map((id) => {
const depTask = taskMap.get(id);
const isCompleted = depTask?.status === 'completed';
const label = depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`;
return (
<button
key={id}
type="button"
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
isCompleted
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
: 'bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/25'
} cursor-pointer`}
title={
depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`
}
onClick={() => handleDependencyClick(id)}
>
{depTask
? formatTaskDisplayLabel(depTask)
: `#${deriveTaskDisplayId(id)}`}
</button>
<Tooltip key={id}>
<TooltipTrigger asChild>
<button
type="button"
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
isCompleted
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
: 'bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/25'
} cursor-pointer`}
onClick={() => handleDependencyClick(id)}
>
{depTask
? formatTaskDisplayLabel(depTask)
: `#${deriveTaskDisplayId(id)}`}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
);
})}
</div>
@ -1025,26 +1042,28 @@ export const TaskDetailDialog = ({
{blocksIds.map((id) => {
const depTask = taskMap.get(id);
const isCompleted = depTask?.status === 'completed';
const label = depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`;
return (
<button
key={id}
type="button"
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
isCompleted
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
: 'bg-blue-500/15 text-blue-400 hover:bg-blue-500/25'
} cursor-pointer`}
title={
depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`
}
onClick={() => handleDependencyClick(id)}
>
{depTask
? formatTaskDisplayLabel(depTask)
: `#${deriveTaskDisplayId(id)}`}
</button>
<Tooltip key={id}>
<TooltipTrigger asChild>
<button
type="button"
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
isCompleted
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
: 'bg-blue-500/15 text-blue-400 hover:bg-blue-500/25'
} cursor-pointer`}
onClick={() => handleDependencyClick(id)}
>
{depTask
? formatTaskDisplayLabel(depTask)
: `#${deriveTaskDisplayId(id)}`}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
);
})}
</div>

View file

@ -201,6 +201,28 @@ export const ChangeReviewDialog = ({
() => sortItemsAsTree(activeChangeSet?.files ?? [], (f) => f.relativePath),
[activeChangeSet]
);
const loadingFiles = useMemo(
() => sortedFiles.filter((file) => fileContentsLoading[file.filePath]),
[sortedFiles, fileContentsLoading]
);
const globalDiffLoadingState = useMemo(() => {
if (loadingFiles.length === 0) return null;
const preferredFile =
(activeFilePath
? loadingFiles.find((file) => file.filePath === activeFilePath)
: undefined) ?? loadingFiles[0];
const snippetCount = loadingFiles.reduce(
(sum, file) => sum + file.snippets.filter((snippet) => !snippet.isError).length,
0
);
return {
loadingFilesCount: loadingFiles.length,
snippetCount,
activeFileName: preferredFile?.relativePath ?? preferredFile?.filePath,
};
}, [activeFilePath, loadingFiles]);
// File paths for viewed tracking
const allFilePaths = useMemo(() => sortedFiles.map((f) => f.filePath), [sortedFiles]);
@ -1217,6 +1239,7 @@ export const ChangeReviewDialog = ({
files={sortedFiles}
fileContents={fileContents}
fileContentsLoading={fileContentsLoading}
globalDiffLoadingState={globalDiffLoadingState}
viewedSet={viewedSet}
editedContents={editedContents}
hunkDecisions={hunkDecisions}

View file

@ -12,6 +12,7 @@ import {
} from './CodeMirrorDiffUtils';
import { FileSectionDiff } from './FileSectionDiff';
import { FileSectionHeader } from './FileSectionHeader';
import { FullDiffLoadingBanner } from './FullDiffLoadingBanner';
import type { EditorView } from '@codemirror/view';
import type { FileChangeWithContent, HunkDecision } from '@shared/types';
@ -22,6 +23,11 @@ interface ContinuousScrollViewProps {
files: FileChangeSummary[];
fileContents: Record<string, FileChangeWithContent>;
fileContentsLoading: Record<string, boolean>;
globalDiffLoadingState?: {
loadingFilesCount: number;
snippetCount: number;
activeFileName?: string;
} | null;
viewedSet: Set<string>;
editedContents: Record<string, string>;
hunkDecisions: Record<string, HunkDecision>;
@ -67,6 +73,7 @@ export const ContinuousScrollView = ({
files,
fileContents,
fileContentsLoading,
globalDiffLoadingState,
viewedSet,
editedContents,
hunkDecisions,
@ -227,6 +234,13 @@ export const ContinuousScrollView = ({
return (
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto">
{globalDiffLoadingState ? (
<FullDiffLoadingBanner
loadingFilesCount={globalDiffLoadingState.loadingFilesCount}
snippetCount={globalDiffLoadingState.snippetCount}
activeFileName={globalDiffLoadingState.activeFileName}
/>
) : null}
{files.map((file) => {
const filePath = file.filePath;
const content = fileContents[filePath] ?? null;

View file

@ -87,9 +87,6 @@ export const FileSectionDiff = ({
return (
<div className="overflow-auto">
<div className="bg-surface-raised/40 border-b border-border px-4 py-2 text-xs text-text-muted">
Loading full diff...
</div>
<ReviewDiffContent file={file} />
<div ref={sentinelRef} className="h-1 shrink-0" />
</div>

View file

@ -1,5 +1,7 @@
import React from 'react';
import { FileDiff, LoaderCircle } from 'lucide-react';
interface FileSectionPlaceholderProps {
fileName: string;
}
@ -7,17 +9,31 @@ interface FileSectionPlaceholderProps {
export const FileSectionPlaceholder = ({
fileName,
}: FileSectionPlaceholderProps): React.ReactElement => (
<div className="animate-pulse">
<div className="flex items-center gap-2 border-b border-border bg-surface-sidebar px-4 py-2">
<span className="text-xs font-medium text-text-muted">{fileName}</span>
<div className="h-4 w-16 rounded bg-surface-raised" />
<div className="bg-surface-raised/70 overflow-hidden rounded-xl border border-border shadow-sm">
<div className="bg-surface-sidebar/80 flex items-center gap-3 border-b border-border px-4 py-3">
<div className="flex size-9 items-center justify-center rounded-xl border border-border bg-surface">
<LoaderCircle className="size-4 animate-spin text-text-secondary" strokeWidth={1.8} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-text">{fileName}</span>
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-surface px-2 py-0.5 text-[10px] uppercase tracking-[0.14em] text-text-muted">
<FileDiff className="size-3" strokeWidth={1.8} />
Loading
</span>
</div>
<p className="mt-1 text-xs text-text-muted">Preparing a full editor diff for this file.</p>
</div>
</div>
<div className="space-y-2 p-4">
<div className="h-4 w-3/4 rounded bg-surface-raised" />
<div className="h-4 w-1/2 rounded bg-surface-raised" />
<div className="h-4 w-5/6 rounded bg-surface-raised" />
<div className="h-4 w-2/3 rounded bg-surface-raised" />
<div className="space-y-3 p-4">
<div className="h-3 w-28 animate-pulse rounded-full bg-surface-sidebar" />
<div className="space-y-2">
<div className="h-4 w-3/4 animate-pulse rounded bg-surface-sidebar" />
<div className="h-4 w-1/2 animate-pulse rounded bg-surface-sidebar" />
<div className="h-4 w-5/6 animate-pulse rounded bg-surface-sidebar" />
<div className="h-4 w-2/3 animate-pulse rounded bg-surface-sidebar" />
</div>
</div>
</div>
);

View file

@ -0,0 +1,89 @@
import React from 'react';
import { Clock3, FileDiff, LoaderCircle, Sparkles } from 'lucide-react';
interface FullDiffLoadingBannerProps {
loadingFilesCount: number;
snippetCount: number;
activeFileName?: string;
}
export const FullDiffLoadingBanner = ({
loadingFilesCount,
snippetCount,
activeFileName,
}: FullDiffLoadingBannerProps): React.ReactElement => {
const title =
loadingFilesCount === 1 ? 'Preparing Full Diff' : `Preparing ${loadingFilesCount} Full Diffs`;
const subtitle =
loadingFilesCount === 1
? activeFileName
? `Finalizing the exact editor diff for ${activeFileName}.`
: 'Finalizing the exact editor diff for the current file.'
: 'Resolving exact before/after baselines for the files currently loading.';
return (
<div className="bg-surface/95 border-b border-border px-4 py-3">
<div className="bg-surface-raised/80 rounded-xl border border-border shadow-sm">
<div className="flex items-start gap-3 px-3 py-3">
<div className="relative mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-xl border border-border bg-surface-sidebar">
<div className="absolute inset-1 rounded-lg bg-emerald-500/10 blur-sm" />
<LoaderCircle
className="relative size-4 animate-spin text-emerald-400"
strokeWidth={1.8}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-emerald-300">
<Sparkles className="size-3" strokeWidth={1.8} />
{title}
</span>
{activeFileName ? (
<span className="truncate text-sm font-medium text-text">{activeFileName}</span>
) : null}
</div>
<p className="mt-1 text-xs leading-5 text-text-secondary">{subtitle}</p>
<div className="mt-2 flex flex-wrap gap-2">
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-surface-sidebar px-2 py-1 text-[11px] text-text-secondary">
<FileDiff className="size-3.5" strokeWidth={1.8} />
{snippetCount} snippet{snippetCount === 1 ? '' : 's'} ready
</span>
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-surface-sidebar px-2 py-1 text-[11px] text-text-secondary">
<Clock3 className="size-3.5" strokeWidth={1.8} />
Editor view loading
</span>
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-surface-sidebar px-2 py-1 text-[11px] text-text-secondary">
<FileDiff className="size-3.5" strokeWidth={1.8} />
{loadingFilesCount} file{loadingFilesCount === 1 ? '' : 's'} in progress
</span>
</div>
</div>
</div>
<div className="px-3 pb-3">
<div className="h-1.5 overflow-hidden rounded-full bg-surface-sidebar">
<div
className="h-full w-1/3 rounded-full bg-gradient-to-r from-emerald-400/20 via-emerald-300/80 to-emerald-400/20"
style={{ animation: 'full-diff-loader-slide 1.6s ease-in-out infinite' }}
/>
</div>
<p className="mt-2 text-[11px] text-text-muted">
Snippet previews stay visible below while the exact baselines are reconstructed.
</p>
</div>
</div>
<style>{`
@keyframes full-diff-loader-slide {
0% { transform: translateX(-110%); }
50% { transform: translateX(110%); }
100% { transform: translateX(320%); }
}
`}</style>
</div>
);
};