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:
parent
cbd09199b3
commit
217eafe6a2
10 changed files with 418 additions and 240 deletions
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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{' '}
|
“
|
||||||
<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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue