feat: add yet-another-react-lightbox for enhanced image handling
- Integrated yet-another-react-lightbox to improve image preview functionality across various components. - Updated ImageLightbox and AttachmentDisplay to utilize the new lightbox for displaying images. - Enhanced TaskAttachments and TaskCommentsSection to support image lightbox previews, improving user experience when viewing attachments. - Refactored SendMessageDialog and EditorImagePreview to ensure consistent image handling with the new lightbox implementation.
This commit is contained in:
parent
f1d08e6d33
commit
8ada8dfcf5
19 changed files with 531 additions and 346 deletions
|
|
@ -134,6 +134,7 @@
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
|
"yet-another-react-lightbox": "^3.29.1",
|
||||||
"zustand": "^4.5.0"
|
"zustand": "^4.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,9 @@ importers:
|
||||||
unified:
|
unified:
|
||||||
specifier: ^11.0.5
|
specifier: ^11.0.5
|
||||||
version: 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:
|
zustand:
|
||||||
specifier: ^4.5.0
|
specifier: ^4.5.0
|
||||||
version: 4.5.7(@types/react@18.3.27)(react@18.3.1)
|
version: 4.5.7(@types/react@18.3.27)(react@18.3.1)
|
||||||
|
|
@ -6164,6 +6167,20 @@ packages:
|
||||||
yauzl@2.10.0:
|
yauzl@2.10.0:
|
||||||
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
|
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:
|
yocto-queue@0.1.0:
|
||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -13183,6 +13200,14 @@ snapshots:
|
||||||
buffer-crc32: 0.2.13
|
buffer-crc32: 0.2.13
|
||||||
fd-slicer: 1.1.0
|
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: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
zip-stream@4.1.1:
|
zip-stream@4.1.1:
|
||||||
|
|
|
||||||
|
|
@ -1516,7 +1516,11 @@ export class TeamAgentToolsInstaller {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current?.includes(`TOOL_VERSION = '${APP_VERSION}'`)) {
|
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);
|
await atomicWriteAsync(toolPath, desired);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
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';
|
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: 'team', label: 'Team', icon: Users },
|
||||||
{ id: 'sessions', label: 'Sessions', icon: History },
|
{ id: 'sessions', label: 'Sessions', icon: History },
|
||||||
{ id: 'kanban', label: 'Kanban', icon: Columns3 },
|
{ id: 'kanban', label: 'Kanban', icon: Columns3 },
|
||||||
|
{ id: 'claude-logs', label: 'Claude Logs', icon: Terminal },
|
||||||
{ id: 'messages', label: 'Messages', icon: MessageSquare },
|
{ id: 'messages', label: 'Messages', icon: MessageSquare },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -157,8 +157,17 @@ export const GlobalTaskList = ({
|
||||||
variant: 'danger',
|
variant: 'danger',
|
||||||
});
|
});
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
await softDeleteTask(teamName, taskId);
|
try {
|
||||||
await fetchAllTasks();
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,7 @@ export const ProvisioningProgressBlock = ({
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="mt-2 flex items-center gap-1 overflow-x-auto pb-0.5">
|
<div className="mt-2 flex items-center justify-center gap-1 overflow-x-auto pb-0.5">
|
||||||
{STEP_ORDER.filter((s): s is ProvisioningStep => s !== 'ready').map((step, index) => {
|
{STEP_ORDER.filter((s): s is ProvisioningStep => s !== 'ready').map((step, index) => {
|
||||||
const isDone = currentStepIndex >= 0 && index < currentStepIndex;
|
const isDone = currentStepIndex >= 0 && index < currentStepIndex;
|
||||||
const isCurrent = currentStepIndex >= 0 && index === currentStepIndex;
|
const isCurrent = currentStepIndex >= 0 && index === currentStepIndex;
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
clearProvisioningError,
|
clearProvisioningError,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivityByTeam,
|
leadActivityByTeam,
|
||||||
|
leadContextByTeam,
|
||||||
refreshTeamData,
|
refreshTeamData,
|
||||||
kanbanFilterQuery,
|
kanbanFilterQuery,
|
||||||
clearKanbanFilter,
|
clearKanbanFilter,
|
||||||
|
|
@ -262,6 +263,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
(run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state)
|
(run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state)
|
||||||
),
|
),
|
||||||
leadActivityByTeam: s.leadActivityByTeam,
|
leadActivityByTeam: s.leadActivityByTeam,
|
||||||
|
leadContextByTeam: s.leadContextByTeam,
|
||||||
refreshTeamData: s.refreshTeamData,
|
refreshTeamData: s.refreshTeamData,
|
||||||
kanbanFilterQuery: s.kanbanFilterQuery,
|
kanbanFilterQuery: s.kanbanFilterQuery,
|
||||||
clearKanbanFilter: s.clearKanbanFilter,
|
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);
|
const total = processes.reduce((sum, p) => sum + (p.metrics.costUsd ?? 0), 0);
|
||||||
return total > 0 ? total : undefined;
|
return total > 0 ? total : undefined;
|
||||||
}, [leadSessionDetail?.processes]);
|
}, [leadSessionDetail?.processes]);
|
||||||
|
const leadContextPercent = leadContextByTeam[teamName]?.percent;
|
||||||
|
|
||||||
const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => {
|
const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => {
|
||||||
if (!leadSessionLoaded || !leadSessionContextStats || !leadConversation?.items.length) {
|
if (!leadSessionLoaded || !leadSessionContextStats || !leadConversation?.items.length) {
|
||||||
|
|
@ -431,6 +434,15 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens };
|
return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens };
|
||||||
}, [leadSessionLoaded, leadSessionContextStats, leadConversation, selectedContextPhase, leadSessionPhaseInfo]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
|
|
||||||
|
|
@ -715,7 +727,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
variant: 'danger',
|
variant: 'danger',
|
||||||
});
|
});
|
||||||
if (confirmed) {
|
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
|
||||||
<div className="flex size-full overflow-hidden">
|
<div className="flex size-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className="size-full flex-1 overflow-auto p-4"
|
className="relative size-full flex-1 overflow-auto p-4"
|
||||||
data-team-name={teamName}
|
data-team-name={teamName}
|
||||||
>
|
>
|
||||||
{/* Sticky Context button (same interaction as Session view) */}
|
{/* Sticky context button in top-right while scrolling */}
|
||||||
{leadSessionId && (
|
{leadSessionId && (
|
||||||
<div className="pointer-events-none sticky top-0 z-10 flex justify-end pb-0 pt-3">
|
<div className="pointer-events-none sticky top-0 z-20 ml-auto w-fit pb-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const next = !isContextPanelVisible;
|
const next = !isContextPanelVisible;
|
||||||
|
|
@ -879,7 +895,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
: leadSessionId
|
: leadSessionId
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{leadSessionLoaded ? `Context (${allContextInjections.length})` : 'Context'}
|
{visibleContextPercentOfTotal !== null
|
||||||
|
? `${visibleContextPercentOfTotal.toFixed(1)}% of total`
|
||||||
|
: typeof leadContextPercent === 'number'
|
||||||
|
? `${Math.round(leadContextPercent)}%`
|
||||||
|
: 'Context'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1192,18 +1212,34 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
onRequestReview={(taskId) => {
|
onRequestReview={(taskId) => {
|
||||||
void requestReview(teamName, taskId);
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await requestReview(teamName, taskId);
|
||||||
|
} catch {
|
||||||
|
// error via store
|
||||||
|
}
|
||||||
|
})();
|
||||||
}}
|
}}
|
||||||
onApprove={(taskId) => {
|
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) => {
|
onRequestChanges={(taskId) => {
|
||||||
setRequestChangesTaskId(taskId);
|
setRequestChangesTaskId(taskId);
|
||||||
}}
|
}}
|
||||||
onMoveBackToDone={(taskId) => {
|
onMoveBackToDone={(taskId) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await updateKanban(teamName, taskId, { op: 'remove' });
|
try {
|
||||||
await updateTaskStatus(teamName, taskId, 'completed');
|
await updateKanban(teamName, taskId, { op: 'remove' });
|
||||||
|
await updateTaskStatus(teamName, taskId, 'completed');
|
||||||
|
} catch {
|
||||||
|
// error via store
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
}}
|
}}
|
||||||
onStartTask={(taskId) => {
|
onStartTask={(taskId) => {
|
||||||
|
|
@ -1237,7 +1273,13 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
})();
|
})();
|
||||||
}}
|
}}
|
||||||
onCompleteTask={(taskId) => {
|
onCompleteTask={(taskId) => {
|
||||||
void updateTaskStatus(teamName, taskId, 'completed');
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await updateTaskStatus(teamName, taskId, 'completed');
|
||||||
|
} catch {
|
||||||
|
// error via store
|
||||||
|
}
|
||||||
|
})();
|
||||||
}}
|
}}
|
||||||
onCancelTask={(taskId) => {
|
onCancelTask={(taskId) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
|
|
||||||
|
|
@ -382,7 +382,11 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
variant: 'danger',
|
variant: 'danger',
|
||||||
});
|
});
|
||||||
if (confirmed) {
|
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(
|
const handleRestoreTeam = useCallback(
|
||||||
(teamName: string, e: React.MouseEvent) => {
|
(teamName: string, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
void restoreTeam(teamName);
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await restoreTeam(teamName);
|
||||||
|
} catch {
|
||||||
|
// error via store
|
||||||
|
}
|
||||||
|
})();
|
||||||
},
|
},
|
||||||
[restoreTeam]
|
[restoreTeam]
|
||||||
);
|
);
|
||||||
|
|
@ -409,7 +419,11 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
variant: 'danger',
|
variant: 'danger',
|
||||||
});
|
});
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
void permanentlyDeleteTeam(teamName);
|
try {
|
||||||
|
await permanentlyDeleteTeam(teamName);
|
||||||
|
} catch {
|
||||||
|
// error via store
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -87,10 +87,10 @@ export const AttachmentDisplay = ({
|
||||||
</div>
|
</div>
|
||||||
{lightboxIndex !== null && items[lightboxIndex] ? (
|
{lightboxIndex !== null && items[lightboxIndex] ? (
|
||||||
<ImageLightbox
|
<ImageLightbox
|
||||||
src={items[lightboxIndex].dataUrl}
|
|
||||||
alt={items[lightboxIndex].meta.filename}
|
|
||||||
open
|
open
|
||||||
onClose={() => setLightboxIndex(null)}
|
onClose={() => setLightboxIndex(null)}
|
||||||
|
slides={items.map((item) => ({ src: item.dataUrl, alt: item.meta.filename }))}
|
||||||
|
index={lightboxIndex}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
src: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageLightboxProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
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 = ({
|
export const ImageLightbox = ({
|
||||||
src,
|
|
||||||
alt = 'Image',
|
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
|
slides: slidesProp,
|
||||||
|
index = 0,
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
enableZoom = true,
|
||||||
|
enableFullscreen = true,
|
||||||
|
showCounter,
|
||||||
}: ImageLightboxProps): React.JSX.Element | null => {
|
}: ImageLightboxProps): React.JSX.Element | null => {
|
||||||
const handleKeyDown = useCallback(
|
const slides = useMemo<Slide[]>(() => {
|
||||||
(e: KeyboardEvent) => {
|
if (slidesProp && slidesProp.length > 0) {
|
||||||
if (e.key === 'Escape') onClose();
|
return slidesProp.map((s) => ({ src: s.src, alt: s.alt, title: s.title }));
|
||||||
},
|
}
|
||||||
[onClose]
|
if (src) {
|
||||||
);
|
return [{ src, alt }];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [slidesProp, src, alt]);
|
||||||
|
|
||||||
useEffect(() => {
|
const plugins = useMemo<Plugin[]>(() => {
|
||||||
if (!open) return;
|
const list: Plugin[] = [];
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
if (enableZoom) list.push(Zoom);
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
if (enableFullscreen) list.push(Fullscreen);
|
||||||
}, [open, handleKeyDown]);
|
// 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 (
|
return (
|
||||||
<div
|
<Lightbox
|
||||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm duration-150 animate-in fade-in"
|
open={open}
|
||||||
role="dialog"
|
close={onClose}
|
||||||
aria-modal="true"
|
slides={slides}
|
||||||
aria-label={alt}
|
index={index}
|
||||||
>
|
plugins={plugins}
|
||||||
<button
|
carousel={{ finite: slides.length <= 1 }}
|
||||||
type="button"
|
animation={{ fade: 200 }}
|
||||||
className="absolute inset-0 border-0 bg-transparent p-0"
|
zoom={{
|
||||||
onClick={onClose}
|
maxZoomPixelRatio: 5,
|
||||||
aria-label="Close"
|
scrollToZoom: true,
|
||||||
/>
|
}}
|
||||||
<button
|
styles={{
|
||||||
type="button"
|
container: { backgroundColor: 'rgba(0, 0, 0, 0.85)', backdropFilter: 'blur(8px)' },
|
||||||
className="relative z-10 max-h-[85vh] max-w-[90vw] border-0 bg-transparent p-0"
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
/>
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={src}
|
|
||||||
alt={alt}
|
|
||||||
className="rounded-lg object-contain shadow-2xl"
|
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,9 @@ import {
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
import { Input } from '@renderer/components/ui/input';
|
import { Input } from '@renderer/components/ui/input';
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
|
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
|
||||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
|
||||||
import { useAttachments } from '@renderer/hooks/useAttachments';
|
import { useAttachments } from '@renderer/hooks/useAttachments';
|
||||||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
|
|
@ -28,7 +27,7 @@ import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
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';
|
import { MemberBadge } from '../MemberBadge';
|
||||||
|
|
||||||
|
|
@ -82,15 +81,6 @@ export const SendMessageDialog = ({
|
||||||
onClose,
|
onClose,
|
||||||
}: SendMessageDialogProps): React.JSX.Element => {
|
}: SendMessageDialogProps): React.JSX.Element => {
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
const recipientOptions = useMemo<ComboboxOption[]>(
|
|
||||||
() =>
|
|
||||||
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 projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
|
||||||
const [quote, setQuote] = useState<QuotedMessage | undefined>(undefined);
|
const [quote, setQuote] = useState<QuotedMessage | undefined>(undefined);
|
||||||
const [quoteExpanded, setQuoteExpanded] = useState(false);
|
const [quoteExpanded, setQuoteExpanded] = useState(false);
|
||||||
|
|
@ -286,43 +276,12 @@ export const SendMessageDialog = ({
|
||||||
<div className="grid gap-4 py-2">
|
<div className="grid gap-4 py-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="smd-recipient">Recipient</Label>
|
<Label htmlFor="smd-recipient">Recipient</Label>
|
||||||
<Combobox
|
<MemberSelect
|
||||||
value={member}
|
members={members}
|
||||||
onValueChange={setMember}
|
value={member || null}
|
||||||
|
onChange={(v) => setMember(v ?? '')}
|
||||||
placeholder="Select member..."
|
placeholder="Select member..."
|
||||||
searchPlaceholder="Search members..."
|
size="sm"
|
||||||
emptyMessage="No members found."
|
|
||||||
options={recipientOptions}
|
|
||||||
renderOption={(option, isSelected) => {
|
|
||||||
const resolvedColor = colorMap.get(option.value);
|
|
||||||
const optionColorSet = resolvedColor ? getTeamColorSet(resolvedColor) : null;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{optionColorSet ? (
|
|
||||||
<span
|
|
||||||
className="mr-2 inline-block size-2 shrink-0 rounded-full"
|
|
||||||
style={{ backgroundColor: optionColorSet.border }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="mr-2 inline-block size-2 shrink-0 rounded-full bg-[var(--color-text-muted)]" />
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className="min-w-0 truncate font-medium"
|
|
||||||
style={optionColorSet ? { color: optionColorSet.text } : undefined}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</span>
|
|
||||||
{option.description ? (
|
|
||||||
<span className="ml-1 shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
|
||||||
{option.description}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{isSelected ? (
|
|
||||||
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { useStore } from '@renderer/store';
|
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 { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
||||||
|
|
||||||
import type { TaskAttachmentMeta } from '@shared/types';
|
import type { TaskAttachmentMeta } from '@shared/types';
|
||||||
|
|
@ -30,14 +31,21 @@ export const TaskAttachments = ({
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [previewAttachment, setPreviewAttachment] = useState<{
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
id: string;
|
const [thumbCache, setThumbCache] = useState<Map<string, string>>(new Map());
|
||||||
mimeType: string;
|
|
||||||
dataUrl: string | null;
|
|
||||||
loading: boolean;
|
|
||||||
} | null>(null);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(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(
|
const handleFileSelect = useCallback(
|
||||||
async (files: FileList | null) => {
|
async (files: FileList | null) => {
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
|
|
@ -79,16 +87,13 @@ export const TaskAttachments = ({
|
||||||
setDeletingId(attachmentId);
|
setDeletingId(attachmentId);
|
||||||
try {
|
try {
|
||||||
await deleteTaskAttachment(teamName, taskId, attachmentId, mimeType);
|
await deleteTaskAttachment(teamName, taskId, attachmentId, mimeType);
|
||||||
if (previewAttachment?.id === attachmentId) {
|
|
||||||
setPreviewAttachment(null);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to delete');
|
setError(err instanceof Error ? err.message : 'Failed to delete');
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null);
|
setDeletingId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[teamName, taskId, deleteTaskAttachment, previewAttachment]
|
[teamName, taskId, deleteTaskAttachment]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDownload = useCallback(
|
const handleDownload = useCallback(
|
||||||
|
|
@ -119,35 +124,15 @@ export const TaskAttachments = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePreview = useCallback(
|
const handlePreview = useCallback(
|
||||||
async (att: TaskAttachmentMeta) => {
|
(att: TaskAttachmentMeta) => {
|
||||||
if (!isImageMimeType(att.mimeType)) {
|
if (!isImageMimeType(att.mimeType)) {
|
||||||
void handleDownload(att);
|
void handleDownload(att);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (previewAttachment?.id === att.id && previewAttachment.dataUrl) {
|
const idx = imageAttachments.findIndex((a) => a.id === att.id);
|
||||||
setPreviewAttachment(null);
|
if (idx >= 0) setLightboxIndex(idx);
|
||||||
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');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[teamName, taskId, getTaskAttachmentData, previewAttachment, handleDownload]
|
[imageAttachments, handleDownload]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle paste events for quick image attachment
|
// Handle paste events for quick image attachment
|
||||||
|
|
@ -212,38 +197,28 @@ export const TaskAttachments = ({
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
taskId={taskId}
|
taskId={taskId}
|
||||||
isDeleting={deletingId === att.id}
|
isDeleting={deletingId === att.id}
|
||||||
isPreviewActive={previewAttachment?.id === att.id}
|
|
||||||
onPreview={() => void handlePreview(att)}
|
onPreview={() => void handlePreview(att)}
|
||||||
onDelete={() => void handleDelete(att.id, att.mimeType)}
|
onDelete={() => void handleDelete(att.id, att.mimeType)}
|
||||||
|
onDataLoaded={handleThumbLoaded}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Preview panel */}
|
{/* Image lightbox */}
|
||||||
{previewAttachment ? (
|
{lightboxIndex !== null && (
|
||||||
<div className="relative rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
|
<ImageLightbox
|
||||||
<button
|
open
|
||||||
type="button"
|
onClose={() => setLightboxIndex(null)}
|
||||||
className="absolute right-2 top-2 rounded p-0.5 text-[var(--color-text-muted)] hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
slides={imageAttachments
|
||||||
onClick={() => setPreviewAttachment(null)}
|
.map((att) => {
|
||||||
>
|
const dataUrl = thumbCache.get(att.id);
|
||||||
<X size={14} />
|
return dataUrl ? { src: dataUrl, alt: att.filename } : null;
|
||||||
</button>
|
})
|
||||||
{previewAttachment.loading ? (
|
.filter(Boolean) as { src: string; alt: string }[]}
|
||||||
<div className="flex items-center gap-2 py-4 text-xs text-[var(--color-text-muted)]">
|
index={lightboxIndex}
|
||||||
<Loader2 size={14} className="animate-spin" />
|
/>
|
||||||
Loading image...
|
)}
|
||||||
</div>
|
|
||||||
) : previewAttachment.dataUrl ? (
|
|
||||||
<img
|
|
||||||
src={previewAttachment.dataUrl}
|
|
||||||
alt="Attachment preview"
|
|
||||||
className="max-h-[400px] max-w-full rounded object-contain"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Drop zone indicator */}
|
{/* Drop zone indicator */}
|
||||||
{dragOver ? (
|
{dragOver ? (
|
||||||
|
|
@ -289,9 +264,9 @@ interface AttachmentThumbnailProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
isDeleting: boolean;
|
isDeleting: boolean;
|
||||||
isPreviewActive: boolean;
|
|
||||||
onPreview: () => void;
|
onPreview: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onDataLoaded?: (attachmentId: string, dataUrl: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AttachmentThumbnail = ({
|
const AttachmentThumbnail = ({
|
||||||
|
|
@ -299,9 +274,9 @@ const AttachmentThumbnail = ({
|
||||||
teamName,
|
teamName,
|
||||||
taskId,
|
taskId,
|
||||||
isDeleting,
|
isDeleting,
|
||||||
isPreviewActive,
|
|
||||||
onPreview,
|
onPreview,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onDataLoaded,
|
||||||
}: AttachmentThumbnailProps): React.JSX.Element => {
|
}: AttachmentThumbnailProps): React.JSX.Element => {
|
||||||
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
|
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
|
||||||
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
|
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
|
||||||
|
|
@ -318,7 +293,9 @@ const AttachmentThumbnail = ({
|
||||||
attachment.mimeType
|
attachment.mimeType
|
||||||
);
|
);
|
||||||
if (!cancelled && base64) {
|
if (!cancelled && base64) {
|
||||||
setThumbUrl(`data:${attachment.mimeType};base64,${base64}`);
|
const dataUrl = `data:${attachment.mimeType};base64,${base64}`;
|
||||||
|
setThumbUrl(dataUrl);
|
||||||
|
onDataLoaded?.(attachment.id, dataUrl);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
|
|
@ -327,7 +304,7 @@ const AttachmentThumbnail = ({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]);
|
}, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData, onDataLoaded]);
|
||||||
|
|
||||||
const sizeLabel =
|
const sizeLabel =
|
||||||
attachment.size < 1024
|
attachment.size < 1024
|
||||||
|
|
@ -338,11 +315,7 @@ const AttachmentThumbnail = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group relative flex size-20 cursor-pointer items-center justify-center overflow-hidden rounded border transition-colors ${
|
className={`group relative flex size-20 cursor-pointer items-center justify-center overflow-hidden rounded border transition-colors border-[var(--color-border)] hover:border-[var(--color-border-emphasis)] bg-[var(--color-surface)]`}
|
||||||
isPreviewActive
|
|
||||||
? 'border-blue-500/60 ring-1 ring-blue-500/30'
|
|
||||||
: 'border-[var(--color-border)] hover:border-[var(--color-border-emphasis)]'
|
|
||||||
} bg-[var(--color-surface)]`}
|
|
||||||
onClick={onPreview}
|
onClick={onPreview}
|
||||||
>
|
>
|
||||||
{isImageMimeType(attachment.mimeType) ? (
|
{isImageMimeType(attachment.mimeType) ? (
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
|
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
|
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
|
||||||
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
|
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
|
||||||
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';
|
||||||
|
|
@ -305,22 +306,14 @@ export const TaskCommentsSection = ({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Full-size image preview overlay */}
|
{/* Image lightbox */}
|
||||||
{previewImageUrl ? (
|
{previewImageUrl ? (
|
||||||
<div className="relative mb-3 rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
|
<ImageLightbox
|
||||||
<button
|
open
|
||||||
type="button"
|
onClose={() => setPreviewImageUrl(null)}
|
||||||
className="absolute right-2 top-2 rounded p-0.5 text-[var(--color-text-muted)] hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
src={previewImageUrl}
|
||||||
onClick={() => setPreviewImageUrl(null)}
|
alt="Attachment preview"
|
||||||
>
|
/>
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
<img
|
|
||||||
src={previewImageUrl}
|
|
||||||
alt="Attachment preview"
|
|
||||||
className="max-h-[400px] max-w-full rounded object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!hideInput && (
|
{!hideInput && (
|
||||||
|
|
@ -416,6 +409,7 @@ const CommentAttachmentThumbnail = ({
|
||||||
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
|
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
|
||||||
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
|
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -441,58 +435,76 @@ const CommentAttachmentThumbnail = ({
|
||||||
}, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]);
|
}, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Tooltip>
|
||||||
className="group relative flex size-14 cursor-pointer items-center justify-center overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] transition-colors hover:border-[var(--color-border-emphasis)]"
|
<TooltipTrigger asChild>
|
||||||
onClick={() => {
|
<div
|
||||||
if (isImageMimeType(attachment.mimeType)) {
|
className={`group relative flex size-14 cursor-pointer items-center justify-center overflow-hidden rounded border bg-[var(--color-surface)] transition-colors ${
|
||||||
if (thumbUrl) onPreview(thumbUrl);
|
downloadError
|
||||||
return;
|
? 'border-red-500/60'
|
||||||
}
|
: 'border-[var(--color-border)] hover:border-[var(--color-border-emphasis)]'
|
||||||
void (async () => {
|
}`}
|
||||||
setDownloading(true);
|
onClick={() => {
|
||||||
try {
|
if (isImageMimeType(attachment.mimeType)) {
|
||||||
const base64 = await getTaskAttachmentData(
|
if (thumbUrl) onPreview(thumbUrl);
|
||||||
teamName,
|
return;
|
||||||
taskId,
|
}
|
||||||
attachment.id,
|
void (async () => {
|
||||||
attachment.mimeType
|
setDownloading(true);
|
||||||
);
|
setDownloadError(null);
|
||||||
if (!base64) return;
|
try {
|
||||||
const mime =
|
const base64 = await getTaskAttachmentData(
|
||||||
attachment.mimeType && typeof attachment.mimeType === 'string'
|
teamName,
|
||||||
? attachment.mimeType
|
taskId,
|
||||||
: 'application/octet-stream';
|
attachment.id,
|
||||||
const dataUrl = `data:${mime};base64,${base64}`;
|
attachment.mimeType
|
||||||
const blob = await fetch(dataUrl).then((r) => r.blob());
|
);
|
||||||
const url = URL.createObjectURL(blob);
|
if (!base64) return;
|
||||||
const a = document.createElement('a');
|
const mime =
|
||||||
a.href = url;
|
attachment.mimeType && typeof attachment.mimeType === 'string'
|
||||||
a.download = attachment.filename || 'attachment';
|
? attachment.mimeType
|
||||||
document.body.appendChild(a);
|
: 'application/octet-stream';
|
||||||
a.click();
|
const dataUrl = `data:${mime};base64,${base64}`;
|
||||||
a.remove();
|
const blob = await fetch(dataUrl).then((r) => r.blob());
|
||||||
URL.revokeObjectURL(url);
|
const url = URL.createObjectURL(blob);
|
||||||
} finally {
|
const a = document.createElement('a');
|
||||||
setDownloading(false);
|
a.href = url;
|
||||||
}
|
a.download = attachment.filename || 'attachment';
|
||||||
})();
|
document.body.appendChild(a);
|
||||||
}}
|
a.click();
|
||||||
>
|
a.remove();
|
||||||
{isImageMimeType(attachment.mimeType) ? (
|
URL.revokeObjectURL(url);
|
||||||
thumbUrl ? (
|
} catch (err) {
|
||||||
<img src={thumbUrl} alt={attachment.filename} className="size-full object-cover" />
|
setDownloadError(err instanceof Error ? err.message : 'Download failed');
|
||||||
) : (
|
} finally {
|
||||||
<Loader2 size={12} className="animate-spin text-[var(--color-text-muted)]" />
|
setDownloading(false);
|
||||||
)
|
}
|
||||||
) : downloading ? (
|
})();
|
||||||
<Loader2 size={12} className="animate-spin text-[var(--color-text-muted)]" />
|
}}
|
||||||
|
>
|
||||||
|
{isImageMimeType(attachment.mimeType) ? (
|
||||||
|
thumbUrl ? (
|
||||||
|
<img src={thumbUrl} alt={attachment.filename} className="size-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<Loader2 size={12} className="animate-spin text-[var(--color-text-muted)]" />
|
||||||
|
)
|
||||||
|
) : downloading ? (
|
||||||
|
<Loader2 size={12} className="animate-spin text-[var(--color-text-muted)]" />
|
||||||
|
) : (
|
||||||
|
<File size={14} className="text-[var(--color-text-muted)]" />
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-x-0 bottom-0 truncate bg-black/60 px-0.5 py-px text-center text-[7px] text-white opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
{attachment.filename}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{downloadError ? (
|
||||||
|
<TooltipContent side="top" className="text-red-400">
|
||||||
|
{downloadError}
|
||||||
|
</TooltipContent>
|
||||||
) : (
|
) : (
|
||||||
<File size={14} className="text-[var(--color-text-muted)]" />
|
<TooltipContent side="top">{attachment.filename}</TooltipContent>
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-x-0 bottom-0 truncate bg-black/60 px-0.5 py-px text-center text-[7px] text-white opacity-0 transition-opacity group-hover:opacity-100">
|
</Tooltip>
|
||||||
{attachment.filename}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
|
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
|
||||||
|
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -16,19 +17,10 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
import { Input } from '@renderer/components/ui/input';
|
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 { Textarea } from '@renderer/components/ui/textarea';
|
||||||
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 { markAsRead } from '@renderer/services/commentReadStorage';
|
import { markAsRead } from '@renderer/services/commentReadStorage';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
|
||||||
import {
|
import {
|
||||||
buildMemberColorMap,
|
buildMemberColorMap,
|
||||||
KANBAN_COLUMN_DISPLAY,
|
KANBAN_COLUMN_DISPLAY,
|
||||||
|
|
@ -350,42 +342,14 @@ export const TaskDetailDialog = ({
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
{canReassign ? (
|
{canReassign ? (
|
||||||
<Select
|
<MemberSelect
|
||||||
value={currentTask.owner ?? '__unassigned__'}
|
members={members}
|
||||||
onValueChange={(v) => {
|
value={currentTask.owner ?? null}
|
||||||
onOwnerChange(currentTask.id, v === '__unassigned__' ? null : v);
|
onChange={(v) => onOwnerChange(currentTask.id, v)}
|
||||||
}}
|
allowUnassigned
|
||||||
>
|
size="sm"
|
||||||
<SelectTrigger className="h-8 w-auto min-w-[140px] text-xs">
|
className="min-w-[160px]"
|
||||||
<SelectValue placeholder="Unassigned" />
|
/>
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="__unassigned__">Unassigned</SelectItem>
|
|
||||||
{members.map((m) => {
|
|
||||||
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
|
||||||
const resolvedColor = colorMap.get(m.name);
|
|
||||||
const memberColor = resolvedColor ? getTeamColorSet(resolvedColor) : null;
|
|
||||||
return (
|
|
||||||
<SelectItem key={m.name} value={m.name}>
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
{memberColor ? (
|
|
||||||
<span
|
|
||||||
className="inline-block size-2 shrink-0 rounded-full"
|
|
||||||
style={{ backgroundColor: memberColor.border }}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<span style={memberColor ? { color: memberColor.text } : undefined}>
|
|
||||||
{m.name}
|
|
||||||
</span>
|
|
||||||
{role ? (
|
|
||||||
<span className="text-[var(--color-text-muted)]">({role})</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : currentTask.owner ? (
|
) : currentTask.owner ? (
|
||||||
<MemberBadge
|
<MemberBadge
|
||||||
name={currentTask.owner}
|
name={currentTask.owner}
|
||||||
|
|
|
||||||
|
|
@ -126,10 +126,10 @@ export const EditorImagePreview = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ImageLightbox
|
<ImageLightbox
|
||||||
src={dataUrl}
|
|
||||||
alt={fileName}
|
|
||||||
open={lightboxOpen}
|
open={lightboxOpen}
|
||||||
onClose={() => setLightboxOpen(false)}
|
onClose={() => setLightboxOpen(false)}
|
||||||
|
src={dataUrl}
|
||||||
|
alt={fileName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -270,9 +270,7 @@ export const KanbanTaskCard = ({
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{task.owner ? (
|
{task.owner ? (
|
||||||
<MemberBadge name={task.owner} color={colorMap.get(task.owner)} />
|
<MemberBadge name={task.owner} color={colorMap.get(task.owner)} />
|
||||||
) : (
|
) : null}
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">Unassigned</span>
|
|
||||||
)}
|
|
||||||
{!compact && <TruncatedTitle text={task.subject} className="min-w-0" />}
|
{!compact && <TruncatedTitle text={task.subject} className="min-w-0" />}
|
||||||
</div>
|
</div>
|
||||||
{task.needsClarification ? (
|
{task.needsClarification ? (
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
|
||||||
<tr className="border-t border-[var(--color-border)]">
|
<tr className="border-t border-[var(--color-border)]">
|
||||||
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.id}</td>
|
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.id}</td>
|
||||||
<td className="px-3 py-2 text-sm text-[var(--color-text)]">{task.subject}</td>
|
<td className="px-3 py-2 text-sm text-[var(--color-text)]">{task.subject}</td>
|
||||||
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.owner ?? 'Unassigned'}</td>
|
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.owner ?? '\u2014'}</td>
|
||||||
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
||||||
{task.kanbanColumn && task.kanbanColumn in KANBAN_COLUMN_DISPLAY
|
{task.kanbanColumn && task.kanbanColumn in KANBAN_COLUMN_DISPLAY
|
||||||
? KANBAN_COLUMN_DISPLAY[task.kanbanColumn].label
|
? KANBAN_COLUMN_DISPLAY[task.kanbanColumn].label
|
||||||
|
|
|
||||||
203
src/renderer/components/ui/MemberSelect.tsx
Normal file
203
src/renderer/components/ui/MemberSelect.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<img
|
||||||
|
src={agentAvatarUrl(member.name, avatarSize)}
|
||||||
|
alt=""
|
||||||
|
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`rounded px-1.5 py-0.5 ${textSize} font-medium tracking-wide`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.badge,
|
||||||
|
color: colors.text,
|
||||||
|
border: `1px solid ${colors.border}40`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{member.name === 'team-lead' ? 'lead' : member.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={listboxId}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
`flex ${triggerHeight} w-full items-center justify-between rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1 text-xs shadow-sm transition-colors placeholder:text-[var(--color-text-muted)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] disabled:cursor-not-allowed disabled:opacity-50`,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate text-left">
|
||||||
|
{selectedMember ? (
|
||||||
|
renderMemberInline(selectedMember)
|
||||||
|
) : value === null && allowUnassigned ? (
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">Unassigned</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[var(--color-text-muted)]">{placeholder}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="ml-2 size-3.5 shrink-0 opacity-50" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-[var(--radix-popover-trigger-width)] min-w-[200px] p-0"
|
||||||
|
align="start"
|
||||||
|
sideOffset={4}
|
||||||
|
collisionPadding={8}
|
||||||
|
avoidCollisions
|
||||||
|
>
|
||||||
|
<CommandPrimitive
|
||||||
|
className="flex size-full flex-col overflow-hidden rounded-md bg-[var(--color-surface)]"
|
||||||
|
shouldFilter={false}
|
||||||
|
>
|
||||||
|
<div className="flex items-center border-b border-[var(--color-border)]">
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
value={search}
|
||||||
|
onValueChange={setSearch}
|
||||||
|
placeholder="Search members..."
|
||||||
|
className="flex h-8 w-full border-0 bg-transparent px-2 py-1 text-xs text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CommandPrimitive.List
|
||||||
|
id={listboxId}
|
||||||
|
className="max-h-72 overflow-y-auto overscroll-contain px-2 py-1"
|
||||||
|
onWheel={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<CommandPrimitive.Empty className="py-4 pr-2 text-center text-xs text-[var(--color-text-muted)]">
|
||||||
|
No members found.
|
||||||
|
</CommandPrimitive.Empty>
|
||||||
|
{allowUnassigned && !search.trim() ? (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
value={UNASSIGNED_VALUE}
|
||||||
|
onSelect={() => {
|
||||||
|
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)]"
|
||||||
|
>
|
||||||
|
<span className="text-[var(--color-text-muted)]">Unassigned</span>
|
||||||
|
{value === null ? (
|
||||||
|
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
|
||||||
|
) : null}
|
||||||
|
</CommandPrimitive.Item>
|
||||||
|
) : 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 (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
key={m.name}
|
||||||
|
value={m.name}
|
||||||
|
onSelect={() => {
|
||||||
|
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)]"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={agentAvatarUrl(m.name, avatarSize)}
|
||||||
|
alt=""
|
||||||
|
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="min-w-0 truncate font-medium"
|
||||||
|
style={{ color: colors.text }}
|
||||||
|
>
|
||||||
|
{m.name === 'team-lead' ? 'lead' : m.name}
|
||||||
|
</span>
|
||||||
|
{role ? (
|
||||||
|
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
{role}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{isSelected ? (
|
||||||
|
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
|
||||||
|
) : null}
|
||||||
|
</CommandPrimitive.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandPrimitive.List>
|
||||||
|
</CommandPrimitive>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -8,58 +8,11 @@ vi.mock('electron', () => ({
|
||||||
BrowserWindow: { getAllWindows: vi.fn(() => []) },
|
BrowserWindow: { getAllWindows: vi.fn(() => []) },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@preload/constants/ipcChannels', () => ({
|
// Keep this mock resilient to new exports (avoid drift).
|
||||||
TEAM_LIST: 'team:list',
|
vi.mock('@preload/constants/ipcChannels', async (importOriginal) => {
|
||||||
TEAM_GET_DATA: 'team:getData',
|
const actual = await importOriginal<typeof import('@preload/constants/ipcChannels')>();
|
||||||
TEAM_DELETE_TEAM: 'team:deleteTeam',
|
return { ...actual };
|
||||||
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',
|
|
||||||
}));
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
TEAM_ALIVE_LIST,
|
TEAM_ALIVE_LIST,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue