diff --git a/package.json b/package.json index c427b37a..99fe397f 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,7 @@ "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", "unified": "^11.0.5", + "yet-another-react-lightbox": "^3.29.1", "zustand": "^4.5.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 346399b9..7f916640 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,6 +230,9 @@ importers: unified: specifier: ^11.0.5 version: 11.0.5 + yet-another-react-lightbox: + specifier: ^3.29.1 + version: 3.29.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) zustand: specifier: ^4.5.0 version: 4.5.7(@types/react@18.3.27)(react@18.3.1) @@ -6164,6 +6167,20 @@ packages: yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yet-another-react-lightbox@3.29.1: + resolution: {integrity: sha512-0cpa+wlleiy2cWNjS9qrcY0+SgZQH/4PDx2uupLMI9Ofip1f7pCgZ95PlVp/EsFsO4ufwOTea51bkLhcEXJJSg==} + engines: {node: '>=14'} + peerDependencies: + '@types/react': ^16 || ^17 || ^18 || ^19 + '@types/react-dom': ^16 || ^17 || ^18 || ^19 + react: ^16.8.0 || ^17 || ^18 || ^19 + react-dom: ^16.8.0 || ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -13183,6 +13200,14 @@ snapshots: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 + yet-another-react-lightbox@3.29.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + yocto-queue@0.1.0: {} zip-stream@4.1.1: diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index 02e5b91b..c13ac263 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -1516,7 +1516,11 @@ export class TeamAgentToolsInstaller { } if (current?.includes(`TOOL_VERSION = '${APP_VERSION}'`)) { - return toolPath; + // Even when app version is unchanged, the generated script can evolve. + // Keep the installed tool idempotent by content, not only by version. + if (current === desired) { + return toolPath; + } } await atomicWriteAsync(toolPath, desired); diff --git a/src/renderer/components/layout/TeamTabSectionNav.tsx b/src/renderer/components/layout/TeamTabSectionNav.tsx index 17e33035..913fe1ce 100644 --- a/src/renderer/components/layout/TeamTabSectionNav.tsx +++ b/src/renderer/components/layout/TeamTabSectionNav.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { ChevronDown, Columns3, History, MessageSquare, Users } from 'lucide-react'; +import { ChevronDown, Columns3, History, MessageSquare, Terminal, Users } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; @@ -14,6 +14,7 @@ const SECTIONS: readonly { id: string; label: string; icon: LucideIcon }[] = [ { id: 'team', label: 'Team', icon: Users }, { id: 'sessions', label: 'Sessions', icon: History }, { id: 'kanban', label: 'Kanban', icon: Columns3 }, + { id: 'claude-logs', label: 'Claude Logs', icon: Terminal }, { id: 'messages', label: 'Messages', icon: MessageSquare }, ]; diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index 45d585fb..b160e5d8 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -157,8 +157,17 @@ export const GlobalTaskList = ({ variant: 'danger', }); if (confirmed) { - await softDeleteTask(teamName, taskId); - await fetchAllTasks(); + try { + await softDeleteTask(teamName, taskId); + await fetchAllTasks(); + } catch (err) { + void confirm({ + title: 'Failed to delete task', + message: err instanceof Error ? err.message : 'An unexpected error occurred', + confirmLabel: 'OK', + variant: 'danger', + }); + } } }; diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index 70450e2a..4fdd94c7 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -191,7 +191,7 @@ export const ProvisioningProgressBlock = ({ {message}

) : null} -
+
{STEP_ORDER.filter((s): s is ProvisioningStep => s !== 'ready').map((step, index) => { const isDone = currentStepIndex >= 0 && index < currentStepIndex; const isCurrent = currentStepIndex >= 0 && index === currentStepIndex; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 2cb64c8e..4b7cb936 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -220,6 +220,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele clearProvisioningError, isTeamProvisioning, leadActivityByTeam, + leadContextByTeam, refreshTeamData, kanbanFilterQuery, clearKanbanFilter, @@ -262,6 +263,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele (run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state) ), leadActivityByTeam: s.leadActivityByTeam, + leadContextByTeam: s.leadContextByTeam, refreshTeamData: s.refreshTeamData, kanbanFilterQuery: s.kanbanFilterQuery, clearKanbanFilter: s.clearKanbanFilter, @@ -377,6 +379,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const total = processes.reduce((sum, p) => sum + (p.metrics.costUsd ?? 0), 0); return total > 0 ? total : undefined; }, [leadSessionDetail?.processes]); + const leadContextPercent = leadContextByTeam[teamName]?.percent; const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => { if (!leadSessionLoaded || !leadSessionContextStats || !leadConversation?.items.length) { @@ -431,6 +434,15 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens }; }, [leadSessionLoaded, leadSessionContextStats, leadConversation, selectedContextPhase, leadSessionPhaseInfo]); + const visibleContextTokens = useMemo(() => { + return allContextInjections.reduce((sum, inj) => sum + (inj.estimatedTokens ?? 0), 0); + }, [allContextInjections]); + + const visibleContextPercentOfTotal = useMemo(() => { + if (lastAiGroupTotalTokens === undefined || lastAiGroupTotalTokens <= 0) return null; + return Math.min((visibleContextTokens / lastAiGroupTotalTokens) * 100, 100); + }, [visibleContextTokens, lastAiGroupTotalTokens]); + useEffect(() => { if (!projectId) return; @@ -715,7 +727,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele variant: 'danger', }); if (confirmed) { - void softDeleteTask(teamName, taskId); + try { + await softDeleteTask(teamName, taskId); + } catch { + // error via store + } } })(); }, @@ -844,12 +860,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
- {/* Sticky Context button (same interaction as Session view) */} + {/* Sticky context button in top-right while scrolling */} {leadSessionId && ( -
+
)} @@ -1192,18 +1212,34 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
} onRequestReview={(taskId) => { - void requestReview(teamName, taskId); + void (async () => { + try { + await requestReview(teamName, taskId); + } catch { + // error via store + } + })(); }} onApprove={(taskId) => { - void updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); + } catch { + // error via store + } + })(); }} onRequestChanges={(taskId) => { setRequestChangesTaskId(taskId); }} onMoveBackToDone={(taskId) => { void (async () => { - await updateKanban(teamName, taskId, { op: 'remove' }); - await updateTaskStatus(teamName, taskId, 'completed'); + try { + await updateKanban(teamName, taskId, { op: 'remove' }); + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } })(); }} onStartTask={(taskId) => { @@ -1237,7 +1273,13 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele })(); }} onCompleteTask={(taskId) => { - void updateTaskStatus(teamName, taskId, 'completed'); + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); }} onCancelTask={(taskId) => { void (async () => { diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 60f98b39..3b508855 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -382,7 +382,11 @@ export const TeamListView = (): React.JSX.Element => { variant: 'danger', }); if (confirmed) { - void deleteTeam(teamName); + try { + await deleteTeam(teamName); + } catch { + // error via store + } } })(); }, @@ -392,7 +396,13 @@ export const TeamListView = (): React.JSX.Element => { const handleRestoreTeam = useCallback( (teamName: string, e: React.MouseEvent) => { e.stopPropagation(); - void restoreTeam(teamName); + void (async () => { + try { + await restoreTeam(teamName); + } catch { + // error via store + } + })(); }, [restoreTeam] ); @@ -409,7 +419,11 @@ export const TeamListView = (): React.JSX.Element => { variant: 'danger', }); if (confirmed) { - void permanentlyDeleteTeam(teamName); + try { + await permanentlyDeleteTeam(teamName); + } catch { + // error via store + } } })(); }, diff --git a/src/renderer/components/team/attachments/AttachmentDisplay.tsx b/src/renderer/components/team/attachments/AttachmentDisplay.tsx index b3461d6a..cd6291c8 100644 --- a/src/renderer/components/team/attachments/AttachmentDisplay.tsx +++ b/src/renderer/components/team/attachments/AttachmentDisplay.tsx @@ -87,10 +87,10 @@ export const AttachmentDisplay = ({
{lightboxIndex !== null && items[lightboxIndex] ? ( setLightboxIndex(null)} + slides={items.map((item) => ({ src: item.dataUrl, alt: item.meta.filename }))} + index={lightboxIndex} /> ) : null} diff --git a/src/renderer/components/team/attachments/ImageLightbox.tsx b/src/renderer/components/team/attachments/ImageLightbox.tsx index 34169fe9..51e3fd47 100644 --- a/src/renderer/components/team/attachments/ImageLightbox.tsx +++ b/src/renderer/components/team/attachments/ImageLightbox.tsx @@ -1,58 +1,85 @@ -import { useCallback, useEffect } from 'react'; +import { useMemo } from 'react'; -interface ImageLightboxProps { +import Lightbox from 'yet-another-react-lightbox'; +import Counter from 'yet-another-react-lightbox/plugins/counter'; +import Fullscreen from 'yet-another-react-lightbox/plugins/fullscreen'; +import Zoom from 'yet-another-react-lightbox/plugins/zoom'; +import 'yet-another-react-lightbox/styles.css'; +import 'yet-another-react-lightbox/plugins/counter.css'; + +import type { Plugin, Slide } from 'yet-another-react-lightbox'; + +export interface ImageLightboxSlide { src: string; alt?: string; + title?: string; +} + +interface ImageLightboxProps { open: boolean; onClose: () => void; + /** Array of slides for gallery mode. */ + slides?: ImageLightboxSlide[]; + /** Starting slide index (default: 0). */ + index?: number; + /** Single image src — convenience shorthand for `slides={[{ src }]}`. */ + src?: string; + /** Alt text for single-image mode. */ + alt?: string; + enableZoom?: boolean; + enableFullscreen?: boolean; + showCounter?: boolean; } export const ImageLightbox = ({ - src, - alt = 'Image', open, onClose, + slides: slidesProp, + index = 0, + src, + alt, + enableZoom = true, + enableFullscreen = true, + showCounter, }: ImageLightboxProps): React.JSX.Element | null => { - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }, - [onClose] - ); + const slides = useMemo(() => { + if (slidesProp && slidesProp.length > 0) { + return slidesProp.map((s) => ({ src: s.src, alt: s.alt, title: s.title })); + } + if (src) { + return [{ src, alt }]; + } + return []; + }, [slidesProp, src, alt]); - useEffect(() => { - if (!open) return; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [open, handleKeyDown]); + const plugins = useMemo(() => { + const list: Plugin[] = []; + if (enableZoom) list.push(Zoom); + if (enableFullscreen) list.push(Fullscreen); + // Show counter only when multiple slides (unless explicitly set) + const shouldShowCounter = showCounter ?? slides.length > 1; + if (shouldShowCounter) list.push(Counter); + return list; + }, [enableZoom, enableFullscreen, showCounter, slides.length]); - if (!open) return null; + if (!open || slides.length === 0) return null; return ( -
- -
+ ); }; diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 339c244c..4916a5ff 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -14,10 +14,9 @@ import { } from '@renderer/components/ui/dialog'; import { Input } from '@renderer/components/ui/input'; import { Label } from '@renderer/components/ui/label'; +import { MemberSelect } from '@renderer/components/ui/MemberSelect'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; -import { Combobox, type ComboboxOption } from '@renderer/components/ui/combobox'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; -import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useAttachments } from '@renderer/hooks/useAttachments'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; @@ -28,7 +27,7 @@ import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; -import { AlertCircle, Check, ImagePlus, Send, X } from 'lucide-react'; +import { AlertCircle, ImagePlus, Send, X } from 'lucide-react'; import { MemberBadge } from '../MemberBadge'; @@ -82,15 +81,6 @@ export const SendMessageDialog = ({ onClose, }: SendMessageDialogProps): React.JSX.Element => { const colorMap = useMemo(() => buildMemberColorMap(members), [members]); - const recipientOptions = useMemo( - () => - members.map((m) => ({ - value: m.name, - label: m.name, - description: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined, - })), - [members] - ); const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); const [quote, setQuote] = useState(undefined); const [quoteExpanded, setQuoteExpanded] = useState(false); @@ -286,43 +276,12 @@ export const SendMessageDialog = ({
- setMember(v ?? '')} placeholder="Select member..." - searchPlaceholder="Search members..." - emptyMessage="No members found." - options={recipientOptions} - renderOption={(option, isSelected) => { - const resolvedColor = colorMap.get(option.value); - const optionColorSet = resolvedColor ? getTeamColorSet(resolvedColor) : null; - return ( - <> - {optionColorSet ? ( - - ) : ( - - )} - - {option.label} - - {option.description ? ( - - {option.description} - - ) : null} - {isSelected ? ( - - ) : null} - - ); - }} + size="sm" />
diff --git a/src/renderer/components/team/dialogs/TaskAttachments.tsx b/src/renderer/components/team/dialogs/TaskAttachments.tsx index 2bbe3858..5f608fbd 100644 --- a/src/renderer/components/team/dialogs/TaskAttachments.tsx +++ b/src/renderer/components/team/dialogs/TaskAttachments.tsx @@ -2,8 +2,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { useStore } from '@renderer/store'; -import { File, ImagePlus, Loader2, Trash2, X } from 'lucide-react'; +import { File, ImagePlus, Loader2, Trash2 } from 'lucide-react'; +import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox'; import { isImageMimeType } from '@renderer/utils/attachmentUtils'; import type { TaskAttachmentMeta } from '@shared/types'; @@ -30,14 +31,21 @@ export const TaskAttachments = ({ const [uploading, setUploading] = useState(false); const [deletingId, setDeletingId] = useState(null); const [error, setError] = useState(null); - const [previewAttachment, setPreviewAttachment] = useState<{ - id: string; - mimeType: string; - dataUrl: string | null; - loading: boolean; - } | null>(null); + const [lightboxIndex, setLightboxIndex] = useState(null); + const [thumbCache, setThumbCache] = useState>(new Map()); const fileInputRef = useRef(null); + const imageAttachments = attachments.filter((a) => isImageMimeType(a.mimeType)); + + const handleThumbLoaded = useCallback((attachmentId: string, dataUrl: string) => { + setThumbCache((prev) => { + if (prev.get(attachmentId) === dataUrl) return prev; + const next = new Map(prev); + next.set(attachmentId, dataUrl); + return next; + }); + }, []); + const handleFileSelect = useCallback( async (files: FileList | null) => { if (!files || files.length === 0) return; @@ -79,16 +87,13 @@ export const TaskAttachments = ({ setDeletingId(attachmentId); try { await deleteTaskAttachment(teamName, taskId, attachmentId, mimeType); - if (previewAttachment?.id === attachmentId) { - setPreviewAttachment(null); - } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to delete'); } finally { setDeletingId(null); } }, - [teamName, taskId, deleteTaskAttachment, previewAttachment] + [teamName, taskId, deleteTaskAttachment] ); const handleDownload = useCallback( @@ -119,35 +124,15 @@ export const TaskAttachments = ({ ); const handlePreview = useCallback( - async (att: TaskAttachmentMeta) => { + (att: TaskAttachmentMeta) => { if (!isImageMimeType(att.mimeType)) { void handleDownload(att); return; } - if (previewAttachment?.id === att.id && previewAttachment.dataUrl) { - setPreviewAttachment(null); - return; - } - setPreviewAttachment({ id: att.id, mimeType: att.mimeType, dataUrl: null, loading: true }); - try { - const base64 = await getTaskAttachmentData(teamName, taskId, att.id, att.mimeType); - if (base64) { - setPreviewAttachment({ - id: att.id, - mimeType: att.mimeType, - dataUrl: `data:${att.mimeType};base64,${base64}`, - loading: false, - }); - } else { - setPreviewAttachment(null); - setError('Attachment file not found'); - } - } catch { - setPreviewAttachment(null); - setError('Failed to load attachment'); - } + const idx = imageAttachments.findIndex((a) => a.id === att.id); + if (idx >= 0) setLightboxIndex(idx); }, - [teamName, taskId, getTaskAttachmentData, previewAttachment, handleDownload] + [imageAttachments, handleDownload] ); // Handle paste events for quick image attachment @@ -212,38 +197,28 @@ export const TaskAttachments = ({ teamName={teamName} taskId={taskId} isDeleting={deletingId === att.id} - isPreviewActive={previewAttachment?.id === att.id} onPreview={() => void handlePreview(att)} onDelete={() => void handleDelete(att.id, att.mimeType)} + onDataLoaded={handleThumbLoaded} /> ))}
) : null} - {/* Preview panel */} - {previewAttachment ? ( -
- - {previewAttachment.loading ? ( -
- - Loading image... -
- ) : previewAttachment.dataUrl ? ( - Attachment preview - ) : null} -
- ) : null} + {/* Image lightbox */} + {lightboxIndex !== null && ( + setLightboxIndex(null)} + slides={imageAttachments + .map((att) => { + const dataUrl = thumbCache.get(att.id); + return dataUrl ? { src: dataUrl, alt: att.filename } : null; + }) + .filter(Boolean) as { src: string; alt: string }[]} + index={lightboxIndex} + /> + )} {/* Drop zone indicator */} {dragOver ? ( @@ -289,9 +264,9 @@ interface AttachmentThumbnailProps { teamName: string; taskId: string; isDeleting: boolean; - isPreviewActive: boolean; onPreview: () => void; onDelete: () => void; + onDataLoaded?: (attachmentId: string, dataUrl: string) => void; } const AttachmentThumbnail = ({ @@ -299,9 +274,9 @@ const AttachmentThumbnail = ({ teamName, taskId, isDeleting, - isPreviewActive, onPreview, onDelete, + onDataLoaded, }: AttachmentThumbnailProps): React.JSX.Element => { const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData); const [thumbUrl, setThumbUrl] = useState(null); @@ -318,7 +293,9 @@ const AttachmentThumbnail = ({ attachment.mimeType ); if (!cancelled && base64) { - setThumbUrl(`data:${attachment.mimeType};base64,${base64}`); + const dataUrl = `data:${attachment.mimeType};base64,${base64}`; + setThumbUrl(dataUrl); + onDataLoaded?.(attachment.id, dataUrl); } } catch { // ignore @@ -327,7 +304,7 @@ const AttachmentThumbnail = ({ return () => { cancelled = true; }; - }, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]); + }, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData, onDataLoaded]); const sizeLabel = attachment.size < 1024 @@ -338,11 +315,7 @@ const AttachmentThumbnail = ({ return (
{isImageMimeType(attachment.mimeType) ? ( diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 20666c95..f4fcf71d 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; +import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox'; import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; @@ -305,22 +306,14 @@ export const TaskCommentsSection = ({
) : null} - {/* Full-size image preview overlay */} + {/* Image lightbox */} {previewImageUrl ? ( -
- - Attachment preview -
+ setPreviewImageUrl(null)} + src={previewImageUrl} + alt="Attachment preview" + /> ) : null} {!hideInput && ( @@ -416,6 +409,7 @@ const CommentAttachmentThumbnail = ({ const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData); const [thumbUrl, setThumbUrl] = useState(null); const [downloading, setDownloading] = useState(false); + const [downloadError, setDownloadError] = useState(null); useEffect(() => { let cancelled = false; @@ -441,58 +435,76 @@ const CommentAttachmentThumbnail = ({ }, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]); return ( -
{ - if (isImageMimeType(attachment.mimeType)) { - if (thumbUrl) onPreview(thumbUrl); - return; - } - void (async () => { - setDownloading(true); - try { - const base64 = await getTaskAttachmentData( - teamName, - taskId, - attachment.id, - attachment.mimeType - ); - if (!base64) return; - const mime = - attachment.mimeType && typeof attachment.mimeType === 'string' - ? attachment.mimeType - : 'application/octet-stream'; - const dataUrl = `data:${mime};base64,${base64}`; - const blob = await fetch(dataUrl).then((r) => r.blob()); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = attachment.filename || 'attachment'; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - } finally { - setDownloading(false); - } - })(); - }} - > - {isImageMimeType(attachment.mimeType) ? ( - thumbUrl ? ( - {attachment.filename} - ) : ( - - ) - ) : downloading ? ( - + + +
{ + if (isImageMimeType(attachment.mimeType)) { + if (thumbUrl) onPreview(thumbUrl); + return; + } + void (async () => { + setDownloading(true); + setDownloadError(null); + try { + const base64 = await getTaskAttachmentData( + teamName, + taskId, + attachment.id, + attachment.mimeType + ); + if (!base64) return; + const mime = + attachment.mimeType && typeof attachment.mimeType === 'string' + ? attachment.mimeType + : 'application/octet-stream'; + const dataUrl = `data:${mime};base64,${base64}`; + const blob = await fetch(dataUrl).then((r) => r.blob()); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = attachment.filename || 'attachment'; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (err) { + setDownloadError(err instanceof Error ? err.message : 'Download failed'); + } finally { + setDownloading(false); + } + })(); + }} + > + {isImageMimeType(attachment.mimeType) ? ( + thumbUrl ? ( + {attachment.filename} + ) : ( + + ) + ) : downloading ? ( + + ) : ( + + )} +
+ {attachment.filename} +
+
+
+ {downloadError ? ( + + {downloadError} + ) : ( - + {attachment.filename} )} -
- {attachment.filename} -
-
+ ); }; diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 8be7ad13..fa63ac59 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -7,6 +7,7 @@ import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; +import { MemberSelect } from '@renderer/components/ui/MemberSelect'; import { Dialog, DialogContent, @@ -16,19 +17,10 @@ import { DialogTitle, } from '@renderer/components/ui/dialog'; import { Input } from '@renderer/components/ui/input'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@renderer/components/ui/select'; import { Textarea } from '@renderer/components/ui/textarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; -import { getTeamColorSet } from '@renderer/constants/teamColors'; import { markAsRead } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; -import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap, KANBAN_COLUMN_DISPLAY, @@ -350,42 +342,14 @@ export const TaskDetailDialog = ({
{canReassign ? ( - + onOwnerChange(currentTask.id, v)} + allowUnassigned + size="sm" + className="min-w-[160px]" + /> ) : currentTask.owner ? ( setLightboxOpen(false)} + src={dataUrl} + alt={fileName} />
); diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index ca34de0a..e86968a7 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -270,9 +270,7 @@ export const KanbanTaskCard = ({
{task.owner ? ( - ) : ( - Unassigned - )} + ) : null} {!compact && }
{task.needsClarification ? ( diff --git a/src/renderer/components/team/tasks/TaskRow.tsx b/src/renderer/components/team/tasks/TaskRow.tsx index 55f3baec..2fbb7b7e 100644 --- a/src/renderer/components/team/tasks/TaskRow.tsx +++ b/src/renderer/components/team/tasks/TaskRow.tsx @@ -14,7 +14,7 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => { {task.id} {task.subject} - {task.owner ?? 'Unassigned'} + {task.owner ?? '\u2014'} {task.kanbanColumn && task.kanbanColumn in KANBAN_COLUMN_DISPLAY ? KANBAN_COLUMN_DISPLAY[task.kanbanColumn].label diff --git a/src/renderer/components/ui/MemberSelect.tsx b/src/renderer/components/ui/MemberSelect.tsx new file mode 100644 index 00000000..b99c68c5 --- /dev/null +++ b/src/renderer/components/ui/MemberSelect.tsx @@ -0,0 +1,203 @@ +import * as React from 'react'; + +import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { cn } from '@renderer/lib/utils'; +import { formatAgentRole } from '@renderer/utils/formatAgentRole'; +import { agentAvatarUrl, buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { Command as CommandPrimitive } from 'cmdk'; +import { Check, ChevronsUpDown } from 'lucide-react'; + +import { Popover, PopoverContent, PopoverTrigger } from './popover'; + +import type { ResolvedTeamMember } from '@shared/types'; + +interface MemberSelectProps { + members: ResolvedTeamMember[]; + value: string | null; + onChange: (value: string | null) => void; + placeholder?: string; + /** Show "Unassigned" option at the top of the list */ + allowUnassigned?: boolean; + /** Size variant */ + size?: 'sm' | 'md'; + disabled?: boolean; + className?: string; +} + +const UNASSIGNED_VALUE = '__unassigned__'; + +export const MemberSelect = ({ + members, + value, + onChange, + placeholder = 'Select member...', + allowUnassigned = false, + size = 'sm', + disabled = false, + className, +}: MemberSelectProps): React.JSX.Element => { + const [open, setOpen] = React.useState(false); + const [search, setSearch] = React.useState(''); + const listboxId = React.useId(); + + const colorMap = React.useMemo(() => buildMemberColorMap(members), [members]); + const selectedMember = React.useMemo( + () => (value ? members.find((m) => m.name === value) : null), + [members, value], + ); + + const avatarSize = size === 'md' ? 32 : 24; + const avatarClass = size === 'md' ? 'size-6' : 'size-5'; + const textSize = size === 'md' ? 'text-xs' : 'text-[10px]'; + const triggerHeight = size === 'md' ? 'h-9' : 'h-8'; + + const renderMemberInline = (member: ResolvedTeamMember): React.ReactNode => { + const resolvedColor = colorMap.get(member.name); + const colors = getTeamColorSet(resolvedColor ?? ''); + return ( + + + + {member.name === 'team-lead' ? 'lead' : member.name} + + + ); + }; + + return ( + + + + + + +
+ +
+ e.stopPropagation()} + > + + No members found. + + {allowUnassigned && !search.trim() ? ( + { + onChange(null); + setOpen(false); + setSearch(''); + }} + className="relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]" + > + Unassigned + {value === null ? ( + + ) : null} + + ) : null} + {members + .filter((m) => { + if (!search.trim()) return true; + const q = search.toLowerCase(); + return ( + m.name.toLowerCase().includes(q) || + (m.role?.toLowerCase().includes(q) ?? false) || + (m.agentType?.toLowerCase().includes(q) ?? false) + ); + }) + .map((m) => { + const isSelected = m.name === value; + const resolvedColor = colorMap.get(m.name); + const colors = getTeamColorSet(resolvedColor ?? ''); + const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); + + return ( + { + onChange(m.name); + setOpen(false); + setSearch(''); + }} + className="relative flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]" + > + + + {m.name === 'team-lead' ? 'lead' : m.name} + + {role ? ( + + {role} + + ) : null} + {isSelected ? ( + + ) : null} + + ); + })} + +
+
+
+ ); +}; diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 48ed8954..522dd469 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -8,58 +8,11 @@ vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: vi.fn(() => []) }, })); -vi.mock('@preload/constants/ipcChannels', () => ({ - TEAM_LIST: 'team:list', - TEAM_GET_DATA: 'team:getData', - TEAM_DELETE_TEAM: 'team:deleteTeam', - TEAM_PREPARE_PROVISIONING: 'team:prepareProvisioning', - TEAM_CREATE: 'team:create', - TEAM_LAUNCH: 'team:launch', - TEAM_CREATE_CONFIG: 'team:createConfig', - TEAM_CREATE_TASK: 'team:createTask', - TEAM_PROVISIONING_STATUS: 'team:provisioningStatus', - TEAM_CANCEL_PROVISIONING: 'team:cancelProvisioning', - TEAM_PROVISIONING_PROGRESS: 'team:provisioningProgress', - TEAM_SEND_MESSAGE: 'team:sendMessage', - TEAM_REQUEST_REVIEW: 'team:requestReview', - TEAM_UPDATE_KANBAN: 'team:updateKanban', - TEAM_UPDATE_KANBAN_COLUMN_ORDER: 'team:updateKanbanColumnOrder', - TEAM_UPDATE_TASK_STATUS: 'team:updateTaskStatus', - TEAM_UPDATE_TASK_FIELDS: 'team:updateTaskFields', - TEAM_UPDATE_TASK_OWNER: 'team:updateTaskOwner', - TEAM_PROCESS_SEND: 'team:processSend', - TEAM_PROCESS_ALIVE: 'team:processAlive', - TEAM_ALIVE_LIST: 'team:aliveList', - TEAM_STOP: 'team:stop', - TEAM_GET_MEMBER_LOGS: 'team:getMemberLogs', - TEAM_GET_LOGS_FOR_TASK: 'team:getLogsForTask', - TEAM_GET_MEMBER_STATS: 'team:getMemberStats', - TEAM_UPDATE_CONFIG: 'team:updateConfig', - TEAM_START_TASK: 'team:startTask', - TEAM_GET_ALL_TASKS: 'team:getAllTasks', - TEAM_ADD_TASK_COMMENT: 'team:addTaskComment', - TEAM_ADD_MEMBER: 'team:addMember', - TEAM_REPLACE_MEMBERS: 'team:replaceMembers', - TEAM_REMOVE_MEMBER: 'team:removeMember', - TEAM_UPDATE_MEMBER_ROLE: 'team:updateMemberRole', - TEAM_GET_PROJECT_BRANCH: 'team:getProjectBranch', - TEAM_GET_ATTACHMENTS: 'team:getAttachments', - TEAM_KILL_PROCESS: 'team:killProcess', - TEAM_LEAD_ACTIVITY: 'team:leadActivity', - TEAM_LEAD_CONTEXT: 'team:leadContext', - TEAM_SOFT_DELETE_TASK: 'team:softDeleteTask', - TEAM_GET_DELETED_TASKS: 'team:getDeletedTasks', - TEAM_SET_TASK_CLARIFICATION: 'team:setTaskClarification', - TEAM_SHOW_MESSAGE_NOTIFICATION: 'team:showMessageNotification', - TEAM_ADD_TASK_RELATIONSHIP: 'team:addTaskRelationship', - TEAM_REMOVE_TASK_RELATIONSHIP: 'team:removeTaskRelationship', - TEAM_RESTORE: 'team:restoreTeam', - TEAM_PERMANENTLY_DELETE: 'team:permanentlyDeleteTeam', - TEAM_RESTORE_TASK: 'team:restoreTask', - TEAM_SAVE_TASK_ATTACHMENT: 'team:saveTaskAttachment', - TEAM_GET_TASK_ATTACHMENT: 'team:getTaskAttachment', - TEAM_DELETE_TASK_ATTACHMENT: 'team:deleteTaskAttachment', -})); +// Keep this mock resilient to new exports (avoid drift). +vi.mock('@preload/constants/ipcChannels', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual }; +}); import { TEAM_ALIVE_LIST,