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]
|
||||
): 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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]">
|
||||
“
|
||||
</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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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