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] kanbanTaskState?: KanbanState['tasks'][string]
): TeamTaskWithKanban { ): TeamTaskWithKanban {
const reviewState = this.resolveTaskReviewState(task); const reviewState = this.resolveTaskReviewState(task);
const reviewer = kanbanTaskState?.reviewer ?? this.resolveReviewerFromHistory(task) ?? null;
return { return {
...task, ...task,
reviewState, reviewState,
kanbanColumn: getKanbanColumnFromReviewState(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[]> { async listTeams(): Promise<TeamSummary[]> {
return this.configReader.listTeams(); return this.configReader.listTeams();
} }

View file

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

View file

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

View file

@ -2,7 +2,6 @@ import { useCallback, useMemo, useRef, useState } from 'react';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
@ -19,7 +18,9 @@ import {
import { MAX_TEXT_LENGTH } from '@shared/constants'; import { MAX_TEXT_LENGTH } from '@shared/constants';
import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react'; 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 { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types'; import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types';
@ -27,6 +28,7 @@ import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types
const MAX_ATTACHMENTS = 5; const MAX_ATTACHMENTS = 5;
const MAX_FILE_SIZE = 20 * 1024 * 1024; const MAX_FILE_SIZE = 20 * 1024 * 1024;
const ACCEPTED_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); const ACCEPTED_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
const LONG_QUOTE_THRESHOLD = 200;
interface TaskCommentInputProps { interface TaskCommentInputProps {
teamName: string; teamName: string;
@ -64,6 +66,7 @@ export const TaskCommentInput = ({
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]); const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
const [attachError, setAttachError] = useState<string | null>(null); const [attachError, setAttachError] = useState<string | null>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null); const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [quoteExpanded, setQuoteExpanded] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const mentionSuggestions = useMemo<MentionSuggestion[]>( const mentionSuggestions = useMemo<MentionSuggestion[]>(
@ -195,31 +198,17 @@ export const TaskCommentInput = ({
return ( return (
<div> <div>
{replyTo ? ( {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="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">
<div className="min-w-0 flex-1"> {/* Decorative quotation mark */}
<div className="mb-0.5 text-[10px] font-medium text-[var(--color-text-muted)]"> <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]">
Replying to{' '} &ldquo;
<span </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>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="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} onClick={onClearReply}
> >
<X size={12} /> <X size={12} />
@ -227,6 +216,29 @@ export const TaskCommentInput = ({
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="left">Cancel reply</TooltipContent> <TooltipContent side="left">Cancel reply</TooltipContent>
</Tooltip> </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> </div>
) : null} ) : null}
@ -286,6 +298,7 @@ export const TaskCommentInput = ({
/> />
<MentionableTextarea <MentionableTextarea
id={`task-comment-${taskId}`} id={`task-comment-${taskId}`}
className={replyTo ? 'rounded-t-none' : undefined}
placeholder="Add a comment... (Enter to send)" placeholder="Add a comment... (Enter to send)"
value={draft.value} value={draft.value}
onValueChange={draft.setValue} onValueChange={draft.setValue}

View file

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

View file

@ -201,6 +201,28 @@ export const ChangeReviewDialog = ({
() => sortItemsAsTree(activeChangeSet?.files ?? [], (f) => f.relativePath), () => sortItemsAsTree(activeChangeSet?.files ?? [], (f) => f.relativePath),
[activeChangeSet] [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 // File paths for viewed tracking
const allFilePaths = useMemo(() => sortedFiles.map((f) => f.filePath), [sortedFiles]); const allFilePaths = useMemo(() => sortedFiles.map((f) => f.filePath), [sortedFiles]);
@ -1217,6 +1239,7 @@ export const ChangeReviewDialog = ({
files={sortedFiles} files={sortedFiles}
fileContents={fileContents} fileContents={fileContents}
fileContentsLoading={fileContentsLoading} fileContentsLoading={fileContentsLoading}
globalDiffLoadingState={globalDiffLoadingState}
viewedSet={viewedSet} viewedSet={viewedSet}
editedContents={editedContents} editedContents={editedContents}
hunkDecisions={hunkDecisions} hunkDecisions={hunkDecisions}

View file

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

View file

@ -87,9 +87,6 @@ export const FileSectionDiff = ({
return ( return (
<div className="overflow-auto"> <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} /> <ReviewDiffContent file={file} />
<div ref={sentinelRef} className="h-1 shrink-0" /> <div ref={sentinelRef} className="h-1 shrink-0" />
</div> </div>

View file

@ -1,5 +1,7 @@
import React from 'react'; import React from 'react';
import { FileDiff, LoaderCircle } from 'lucide-react';
interface FileSectionPlaceholderProps { interface FileSectionPlaceholderProps {
fileName: string; fileName: string;
} }
@ -7,17 +9,31 @@ interface FileSectionPlaceholderProps {
export const FileSectionPlaceholder = ({ export const FileSectionPlaceholder = ({
fileName, fileName,
}: FileSectionPlaceholderProps): React.ReactElement => ( }: FileSectionPlaceholderProps): React.ReactElement => (
<div className="animate-pulse"> <div className="bg-surface-raised/70 overflow-hidden rounded-xl border border-border shadow-sm">
<div className="flex items-center gap-2 border-b border-border bg-surface-sidebar px-4 py-2"> <div className="bg-surface-sidebar/80 flex items-center gap-3 border-b border-border px-4 py-3">
<span className="text-xs font-medium text-text-muted">{fileName}</span> <div className="flex size-9 items-center justify-center rounded-xl border border-border bg-surface">
<div className="h-4 w-16 rounded bg-surface-raised" /> <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>
<div className="space-y-2 p-4"> <div className="space-y-3 p-4">
<div className="h-4 w-3/4 rounded bg-surface-raised" /> <div className="h-3 w-28 animate-pulse rounded-full bg-surface-sidebar" />
<div className="h-4 w-1/2 rounded bg-surface-raised" /> <div className="space-y-2">
<div className="h-4 w-5/6 rounded bg-surface-raised" /> <div className="h-4 w-3/4 animate-pulse rounded bg-surface-sidebar" />
<div className="h-4 w-2/3 rounded bg-surface-raised" /> <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>
</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>
);
};