feat: enhance attachment handling and improve UI components

- Updated local image storage documentation to reflect changes in protocol handling for attachments.
- Added attachment metadata handling in message sending and inbox writing processes, improving attachment management.
- Implemented size validation for attachments in the TeamAttachmentStore, ensuring compliance with storage limits.
- Enhanced UI components to support attachment previews and improved user experience in managing attachments.
- Introduced a new RoleSelect component for better role management within teams.
This commit is contained in:
iliya 2026-03-05 12:15:03 +02:00
parent 43d2953874
commit b163892d20
45 changed files with 1562 additions and 609 deletions

View file

@ -8,7 +8,7 @@ This document evaluates approaches for storing images/attachments locally in our
## Approach 1: Filesystem + SQLite Metadata (Recommended) ## Approach 1: Filesystem + SQLite Metadata (Recommended)
**How it works:** Store image files on disk under `app.getPath('userData')/attachments/`, serve them to the renderer via a custom `protocol.handle` scheme (`app://attachments/...`), and track metadata (path, original name, size, hash, created date, linked entity) in a `better-sqlite3` table. **How it works:** Store image files on disk under `app.getPath('userData')/attachments/`, serve them to the renderer via a custom `protocol.handle` scheme (`app-img://...`), and track metadata (path, original name, size, hash, created date, linked entity) in a `better-sqlite3` table.
### Pros ### Pros
- Best I/O performance — direct filesystem reads, no serialization overhead. - Best I/O performance — direct filesystem reads, no serialization overhead.

View file

@ -937,6 +937,13 @@ async function handleSendMessage(
} }
if (stdinSent) { if (stdinSent) {
const attachmentMeta: AttachmentMeta[] | undefined = validatedAttachments?.map((a) => ({
id: a.id,
filename: a.filename,
mimeType: a.mimeType,
size: a.size,
}));
// Persistence is best-effort — stdin already delivered the message // Persistence is best-effort — stdin already delivered the message
let result: SendMessageResult; let result: SendMessageResult;
try { try {
@ -944,20 +951,14 @@ async function handleSendMessage(
tn, tn,
leadName, leadName,
payload.text!, payload.text!,
payload.summary payload.summary,
attachmentMeta
); );
} catch (persistError) { } catch (persistError) {
logger.warn(`Persistence failed after stdin delivery for ${tn}: ${String(persistError)}`); logger.warn(`Persistence failed after stdin delivery for ${tn}: ${String(persistError)}`);
result = { deliveredToInbox: false, messageId: `stdin-${Date.now()}` }; result = { deliveredToInbox: false, messageId: `stdin-${Date.now()}` };
} }
const attachmentMeta: AttachmentMeta[] | undefined = validatedAttachments?.map((a) => ({
id: a.id,
filename: a.filename,
mimeType: a.mimeType,
size: a.size,
}));
// Save attachment binary data to disk (best-effort) // Save attachment binary data to disk (best-effort)
if (validatedAttachments?.length && result.messageId) { if (validatedAttachments?.length && result.messageId) {
void attachmentStore void attachmentStore

View file

@ -10,6 +10,7 @@ import type { AttachmentFileData, AttachmentPayload } from '@shared/types';
const logger = createLogger('Service:TeamAttachmentStore'); const logger = createLogger('Service:TeamAttachmentStore');
const ATTACHMENTS_DIR = 'attachments'; const ATTACHMENTS_DIR = 'attachments';
const MAX_ATTACHMENTS_FILE_BYTES = 64 * 1024 * 1024; // 64MB safety cap
export class TeamAttachmentStore { export class TeamAttachmentStore {
private assertSafePathSegment(label: string, value: string): void { private assertSafePathSegment(label: string, value: string): void {
@ -58,6 +59,10 @@ export class TeamAttachmentStore {
let raw: string; let raw: string;
try { try {
const stat = await fs.promises.stat(filePath);
if (!stat.isFile() || stat.size > MAX_ATTACHMENTS_FILE_BYTES) {
return [];
}
raw = await fs.promises.readFile(filePath, 'utf8'); raw = await fs.promises.readFile(filePath, 'utf8');
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') { if ((error as NodeJS.ErrnoException).code === 'ENOENT') {

View file

@ -32,6 +32,7 @@ import { TeamTaskWriter } from './TeamTaskWriter';
import type { import type {
AddMemberRequest, AddMemberRequest,
AttachmentMeta,
CreateTaskRequest, CreateTaskRequest,
GlobalTask, GlobalTask,
InboxMessage, InboxMessage,
@ -631,6 +632,7 @@ export class TeamDataService {
const newMember: TeamMember = { const newMember: TeamMember = {
name: request.name, name: request.name,
role: request.role?.trim() || undefined, role: request.role?.trim() || undefined,
workflow: request.workflow?.trim() || undefined,
agentType: 'general-purpose', agentType: 'general-purpose',
color: getMemberColor(members.filter((m) => !m.removedAt).length), color: getMemberColor(members.filter((m) => !m.removedAt).length),
joinedAt: Date.now(), joinedAt: Date.now(),
@ -1001,6 +1003,8 @@ export class TeamDataService {
]); ]);
const task = tasks.find((t) => t.id === taskId); const task = tasks.find((t) => t.id === taskId);
const leadName = this.resolveLeadNameFromConfig(config); const leadName = this.resolveLeadNameFromConfig(config);
const owner = task?.owner?.trim() || null;
const normalizedOwner = owner?.toLowerCase() ?? null;
// Auto-clear needsClarification: "user" on UI comment // Auto-clear needsClarification: "user" on UI comment
// UI comments always have author "user" (TeamTaskWriter default) // UI comments always have author "user" (TeamTaskWriter default)
@ -1008,7 +1012,7 @@ export class TeamDataService {
await this.taskWriter.setNeedsClarification(teamName, taskId, null); await this.taskWriter.setNeedsClarification(teamName, taskId, null);
} }
if (task?.owner && !this.isLeadOwner(task.owner, leadName)) { if (task && owner && !this.isLeadOwner(owner, leadName)) {
// Notify non-lead task owner via inbox (lead → member message) // Notify non-lead task owner via inbox (lead → member message)
const parts = [ const parts = [
`Comment on task #${taskId} "${task.subject}":\n\n${text}`, `Comment on task #${taskId} "${task.subject}":\n\n${text}`,
@ -1018,12 +1022,12 @@ export class TeamDataService {
AGENT_BLOCK_CLOSE, AGENT_BLOCK_CLOSE,
]; ];
await this.sendMessage(teamName, { await this.sendMessage(teamName, {
member: task.owner, member: owner,
from: leadName, from: leadName,
text: parts.join('\n'), text: parts.join('\n'),
summary: `Comment on #${taskId}`, summary: `Comment on #${taskId}`,
}); });
} else if (task?.owner && this.isLeadOwner(task.owner, leadName)) { } else if (task && owner && this.isLeadOwner(owner, leadName)) {
// Notify lead about user's comment on their own task. // Notify lead about user's comment on their own task.
// Write to lead's inbox — relay delivers to stdin when process is alive. // Write to lead's inbox — relay delivers to stdin when process is alive.
const parts = [ const parts = [
@ -1076,7 +1080,8 @@ export class TeamDataService {
teamName: string, teamName: string,
leadName: string, leadName: string,
text: string, text: string,
summary?: string summary?: string,
attachments?: AttachmentMeta[]
): Promise<SendMessageResult> { ): Promise<SendMessageResult> {
const messageId = randomUUID(); const messageId = randomUUID();
const msg: InboxMessage = { const msg: InboxMessage = {
@ -1088,6 +1093,7 @@ export class TeamDataService {
summary, summary,
messageId, messageId,
source: 'user_sent', source: 'user_sent',
attachments: attachments?.length ? attachments : undefined,
}; };
await this.sentMessagesStore.appendMessage(teamName, msg); await this.sentMessagesStore.appendMessage(teamName, msg);
return { deliveredToInbox: false, deliveredViaStdin: true, messageId }; return { deliveredToInbox: false, deliveredViaStdin: true, messageId };
@ -1217,9 +1223,23 @@ export class TeamDataService {
// Dedup broadcasts: same sender + same text → process only once // Dedup broadcasts: same sender + same text → process only once
const processedTexts = new Set<string>(); const processedTexts = new Set<string>();
function isAutomatedCommentNotification(msg: InboxMessage): boolean {
const summary = typeof msg.summary === 'string' ? msg.summary : '';
if (!/^Comment on #\d+/.test(summary)) return false;
const text = typeof msg.text === 'string' ? msg.text : '';
if (!text) return false;
// These are system-generated inbox messages that already correspond to a real task comment.
// Syncing them into task.comments causes an immediate "duplicate" (lead echo) in the UI.
if (text.includes('Reply to this comment using:')) return true;
if (text.startsWith('Comment on task #')) return true;
if (text.startsWith('New comment from user on your task #')) return true;
return false;
}
for (const msg of messages) { for (const msg of messages) {
if (!msg.messageId || !msg.summary || msg.from === 'user') continue; if (!msg.messageId || !msg.summary || msg.from === 'user') continue;
if (msg.source === 'lead_session' || msg.source === 'lead_process') continue; if (msg.source === 'lead_session' || msg.source === 'lead_process') continue;
if (isAutomatedCommentNotification(msg)) continue;
const textKey = `${msg.from}\0${msg.text}`; const textKey = `${msg.from}\0${msg.text}`;
if (processedTexts.has(textKey)) continue; if (processedTexts.has(textKey)) continue;

View file

@ -13,6 +13,13 @@ export class TeamInboxWriter {
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${request.member}.json`); const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${request.member}.json`);
const messageId = randomUUID(); const messageId = randomUUID();
const attachmentMeta = request.attachments?.map((a) => ({
id: a.id,
filename: a.filename,
mimeType: a.mimeType,
size: a.size,
}));
const payload: InboxMessage = { const payload: InboxMessage = {
from: request.from ?? 'user', from: request.from ?? 'user',
to: request.member, to: request.member,
@ -21,6 +28,7 @@ export class TeamInboxWriter {
read: false, read: false,
summary: request.summary, summary: request.summary,
messageId, messageId,
attachments: attachmentMeta?.length ? attachmentMeta : undefined,
}; };
await withInboxLock(inboxPath, async () => { await withInboxLock(inboxPath, async () => {

View file

@ -1028,7 +1028,8 @@ export class TeamProvisioningService {
const run = this.runs.get(runId); const run = this.runs.get(runId);
if (!run?.leadContextUsage || run.processKilled || run.cancelRequested) return null; if (!run?.leadContextUsage || run.processKilled || run.cancelRequested) return null;
const { currentTokens, contextWindow } = run.leadContextUsage; const { currentTokens, contextWindow } = run.leadContextUsage;
const percent = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0; const percentRaw = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0;
const percent = Math.max(0, Math.min(100, percentRaw));
return { currentTokens, contextWindow, percent, updatedAt: new Date().toISOString() }; return { currentTokens, contextWindow, percent, updatedAt: new Date().toISOString() };
} }
@ -1055,7 +1056,8 @@ export class TeamProvisioningService {
} }
run.leadContextUsage.lastEmittedAt = now; run.leadContextUsage.lastEmittedAt = now;
const { currentTokens, contextWindow } = run.leadContextUsage; const { currentTokens, contextWindow } = run.leadContextUsage;
const percent = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0; const percentRaw = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0;
const percent = Math.max(0, Math.min(100, percentRaw));
const payload: LeadContextUsage = { const payload: LeadContextUsage = {
currentTokens, currentTokens,
contextWindow, contextWindow,
@ -2584,11 +2586,18 @@ export class TeamProvisioningService {
typeof modelData.contextWindow === 'number' && typeof modelData.contextWindow === 'number' &&
modelData.contextWindow > 0 modelData.contextWindow > 0
) { ) {
if (run.leadContextUsage) { if (!run.leadContextUsage) {
run.leadContextUsage = {
currentTokens: 0,
contextWindow: modelData.contextWindow,
lastUsageMessageId: null,
lastEmittedAt: 0,
};
} else {
run.leadContextUsage.contextWindow = modelData.contextWindow; run.leadContextUsage.contextWindow = modelData.contextWindow;
run.leadContextUsage.lastEmittedAt = 0; // force re-emit run.leadContextUsage.lastEmittedAt = 0; // force re-emit
this.emitLeadContextUsage(run);
} }
this.emitLeadContextUsage(run);
break; break;
} }
} }
@ -2610,9 +2619,18 @@ export class TeamProvisioningService {
? resultUsage.cache_read_input_tokens ? resultUsage.cache_read_input_tokens
: 0; : 0;
const total = inp + cc + cr; const total = inp + cc + cr;
if (total > 0 && run.leadContextUsage) { if (total > 0) {
run.leadContextUsage.currentTokens = total; if (!run.leadContextUsage) {
run.leadContextUsage.lastEmittedAt = 0; run.leadContextUsage = {
currentTokens: total,
contextWindow: 0,
lastUsageMessageId: null,
lastEmittedAt: 0,
};
} else {
run.leadContextUsage.currentTokens = total;
run.leadContextUsage.lastEmittedAt = 0;
}
this.emitLeadContextUsage(run); this.emitLeadContextUsage(run);
} }
} }

View file

@ -21,6 +21,9 @@ export class TeamTaskAttachmentStore {
private assertSafePathSegment(label: string, value: string): void { private assertSafePathSegment(label: string, value: string): void {
if ( if (
value.length === 0 || value.length === 0 ||
value.trim().length === 0 ||
value === '.' ||
value === '..' ||
value.includes('/') || value.includes('/') ||
value.includes('\\') || value.includes('\\') ||
value.includes('..') || value.includes('..') ||

View file

@ -172,8 +172,12 @@ export class TeamTaskReader {
: 'pending', : 'pending',
workIntervals, workIntervals,
statusHistory, statusHistory,
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as string[]) : undefined, blocks: Array.isArray(parsed.blocks)
blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as string[]) : undefined, ? (parsed.blocks as unknown[]).filter((id): id is string => typeof id === 'string')
: undefined,
blockedBy: Array.isArray(parsed.blockedBy)
? (parsed.blockedBy as unknown[]).filter((id): id is string => typeof id === 'string')
: undefined,
related: Array.isArray(parsed.related) related: Array.isArray(parsed.related)
? (parsed.related as unknown[]).filter((id): id is string => typeof id === 'string') ? (parsed.related as unknown[]).filter((id): id is string => typeof id === 'string')
: undefined, : undefined,
@ -197,19 +201,30 @@ export class TeamTaskReader {
? c.type ? c.type
: ('regular' as const), : ('regular' as const),
attachments: Array.isArray(c.attachments) attachments: Array.isArray(c.attachments)
? (c.attachments as unknown[]).filter( ? (() => {
(a): a is TaskAttachmentMeta => const filtered = (c.attachments as unknown[])
Boolean(a) && .filter(
typeof a === 'object' && (a): a is TaskAttachmentMeta =>
typeof (a as Record<string, unknown>).id === 'string' && Boolean(a) &&
typeof (a as Record<string, unknown>).filename === 'string' && typeof a === 'object' &&
typeof (a as Record<string, unknown>).mimeType === 'string' && typeof (a as Record<string, unknown>).id === 'string' &&
VALID_ATTACHMENT_MIME_TYPES.has( typeof (a as Record<string, unknown>).filename === 'string' &&
(a as Record<string, unknown>).mimeType as string typeof (a as Record<string, unknown>).mimeType === 'string' &&
) && VALID_ATTACHMENT_MIME_TYPES.has(
typeof (a as Record<string, unknown>).size === 'number' && (a as Record<string, unknown>).mimeType as string
typeof (a as Record<string, unknown>).addedAt === 'string' ) &&
) typeof (a as Record<string, unknown>).size === 'number' &&
typeof (a as Record<string, unknown>).addedAt === 'string'
)
.map((a) => ({
id: a.id,
filename: a.filename,
mimeType: a.mimeType,
size: a.size,
addedAt: a.addedAt,
}));
return filtered.length > 0 ? filtered : undefined;
})()
: undefined, : undefined,
})) }))
: undefined, : undefined,
@ -256,6 +271,9 @@ export class TeamTaskReader {
} }
} }
// Sort by numeric ID so kanban default order is deterministic (#1, #2, ..., #10, #11)
tasks.sort((a, b) => Number(a.id) - Number(b.id));
return tasks; return tasks;
} }

View file

@ -1,8 +1,9 @@
import React from 'react'; import React from 'react';
import ReactMarkdown, { type Components } from 'react-markdown'; import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { CopyButton } from '@renderer/components/common/CopyButton'; import { CopyButton } from '@renderer/components/common/CopyButton';
import { TaskTooltip } from '@renderer/components/team/TaskTooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { import {
CODE_BG, CODE_BG,
@ -60,6 +61,15 @@ interface MarkdownViewerProps {
// Helpers // Helpers
// ============================================================================= // =============================================================================
/**
* Custom URL transform that preserves task:// and mention:// protocols.
* react-markdown v10 strips non-standard protocols by default.
*/
function allowCustomProtocols(url: string): string {
if (url.startsWith('task://') || url.startsWith('mention://')) return url;
return defaultUrlTransform(url);
}
/** Check if a URL is relative (not absolute, not data, not mailto, not hash) */ /** Check if a URL is relative (not absolute, not data, not mailto, not hash) */
function isRelativeUrl(url: string): boolean { function isRelativeUrl(url: string): boolean {
return ( return (
@ -200,13 +210,18 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
), ),
// Links — inline element, no hl(); parent block element's hl() descends here // Links — inline element, no hl(); parent block element's hl() descends here
// task:// links are handled by ancestor onClickCapture handlers (e.g. ActivityItem) // task:// links render with TaskTooltip + are clickable via ancestor onClickCapture
// mention:// links render as colored inline badges // mention:// links render as colored inline badges
a: ({ href, children }) => { a: ({ href, children }) => {
if (href?.startsWith('mention://')) { if (href?.startsWith('mention://')) {
const path = href.slice('mention://'.length); const path = href.slice('mention://'.length);
const slashIdx = path.indexOf('/'); const slashIdx = path.indexOf('/');
const color = slashIdx >= 0 ? decodeURIComponent(path.slice(0, slashIdx)) : ''; let color = '';
try {
color = slashIdx >= 0 ? decodeURIComponent(path.slice(0, slashIdx)) : '';
} catch {
// malformed percent-encoding — use empty color
}
const colorSet = getTeamColorSet(color); const colorSet = getTeamColorSet(color);
const bg = colorSet.badge; const bg = colorSet.badge;
return ( return (
@ -223,6 +238,21 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
</span> </span>
); );
} }
if (href?.startsWith('task://')) {
const taskId = href.slice('task://'.length);
return (
<TaskTooltip taskId={taskId}>
<a
href={href}
className="cursor-pointer font-medium no-underline hover:underline"
style={{ color: PROSE_LINK }}
onClick={(e) => e.preventDefault()}
>
{children}
</a>
</TaskTooltip>
);
}
return ( return (
<a <a
href={href} href={href}
@ -230,7 +260,7 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
style={{ color: PROSE_LINK }} style={{ color: PROSE_LINK }}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
if (href && !href.startsWith('task://')) { if (href) {
void api.openExternal(href); void api.openExternal(href);
} }
}} }}
@ -629,6 +659,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS} rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS}
components={components} components={components}
urlTransform={allowCustomProtocols}
> >
{content} {content}
</ReactMarkdown> </ReactMarkdown>

View file

@ -3,7 +3,7 @@
* Provides UI for managing notifications, display settings, and advanced options. * Provides UI for managing notifications, display settings, and advanced options.
*/ */
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
@ -23,15 +23,13 @@ export const SettingsView = (): React.JSX.Element | null => {
const pendingSettingsSection = useStore((s) => s.pendingSettingsSection); const pendingSettingsSection = useStore((s) => s.pendingSettingsSection);
const clearPendingSettingsSection = useStore((s) => s.clearPendingSettingsSection); const clearPendingSettingsSection = useStore((s) => s.clearPendingSettingsSection);
// Consume pending section during render (React-recommended pattern for adjusting state on prop change) // Consume pending section (avoid setState during render)
const [prevPending, setPrevPending] = useState<string | null>(null); useEffect(() => {
if (pendingSettingsSection !== prevPending) {
setPrevPending(pendingSettingsSection);
if (pendingSettingsSection) { if (pendingSettingsSection) {
setActiveSection(pendingSettingsSection as SettingsSection); setActiveSection(pendingSettingsSection as SettingsSection);
clearPendingSettingsSection(); clearPendingSettingsSection();
} }
} }, [pendingSettingsSection, clearPendingSettingsSection]);
const { const {
config, config,

View file

@ -150,7 +150,7 @@ type VirtualItem =
* Mismatch causes items to overlap! * Mismatch causes items to overlap!
*/ */
const HEADER_HEIGHT = 28; const HEADER_HEIGHT = 28;
const SESSION_HEIGHT = 48; // Must match h-[48px] in SessionItem.tsx const SESSION_HEIGHT = 58; // Must match h-[58px] in SessionItem.tsx
const LOADER_HEIGHT = 36; const LOADER_HEIGHT = 36;
const OVERSCAN = 5; const OVERSCAN = 5;

View file

@ -240,13 +240,13 @@ export const SessionItem = ({
} }
}, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]); }, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]);
// Height must match SESSION_HEIGHT (48px) in DateGroupedSessions.tsx for virtual scroll // Height must match SESSION_HEIGHT (58px) in DateGroupedSessions.tsx for virtual scroll
return ( return (
<> <>
<button <button
onClick={handleClick} onClick={handleClick}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
className={`h-[48px] w-full overflow-hidden border-b px-3 py-2 text-left transition-colors ${isActive ? '' : 'bg-transparent hover:bg-surface-raised'}`} className={`h-[58px] w-full overflow-hidden border-b px-3 py-2 text-left transition-colors ${isActive ? '' : 'bg-transparent hover:bg-surface-raised'}`}
style={{ style={{
borderColor: 'var(--color-border)', borderColor: 'var(--color-border)',
...(isActive ? { backgroundColor: 'var(--color-surface-raised)' } : {}), ...(isActive ? { backgroundColor: 'var(--color-surface-raised)' } : {}),
@ -268,7 +268,7 @@ export const SessionItem = ({
{isPinned && <Pin className="size-2.5 shrink-0 text-blue-400" />} {isPinned && <Pin className="size-2.5 shrink-0 text-blue-400" />}
{isHidden && <EyeOff className="size-2.5 shrink-0 text-zinc-500" />} {isHidden && <EyeOff className="size-2.5 shrink-0 text-zinc-500" />}
<span <span
className="truncate text-[13px] font-medium leading-tight" className="line-clamp-2 text-[13px] font-medium leading-tight"
style={{ color: isActive ? 'var(--color-text)' : 'var(--color-text-muted)' }} style={{ color: isActive ? 'var(--color-text)' : 'var(--color-text-muted)' }}
> >
{session.firstMessage ?? 'Untitled'} {session.firstMessage ?? 'Untitled'}

View file

@ -114,7 +114,7 @@ export const CollapsibleTeamSection = ({
{action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>} {action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>}
</div> </div>
{isOpen && ( {isOpen && (
<div className={`mt-1.5 min-w-0 overflow-x-clip pb-2 ${contentClassName ?? ''}`}> <div className={cn('mt-1.5 min-w-0 overflow-x-clip pb-2', contentClassName)}>
{children} {children}
</div> </div>
)} )}

View file

@ -0,0 +1,158 @@
import React, { useCallback, useMemo, useState } from 'react';
import { Combobox } from '@renderer/components/ui/combobox';
import { Input } from '@renderer/components/ui/input';
import { CUSTOM_ROLE, FORBIDDEN_ROLES, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import {
Blocks,
BookOpen,
Bug,
Check,
Code2,
FileText,
Pencil,
Shield,
Zap,
} from 'lucide-react';
import type { ComboboxOption } from '@renderer/components/ui/combobox';
import type { LucideIcon } from 'lucide-react';
/** Icon mapping for preset roles. */
const ROLE_ICONS: Record<string, LucideIcon> = {
architect: Blocks,
reviewer: BookOpen,
developer: Code2,
qa: Bug,
researcher: BookOpen,
docs: FileText,
auditor: Shield,
optimizer: Zap,
};
const CUSTOM_ICON = Pencil;
interface RoleSelectProps {
/** Current role selection value (preset role name, CUSTOM_ROLE, or NO_ROLE). */
value: string;
/** Called when the user picks a preset role, NO_ROLE, or CUSTOM_ROLE. */
onValueChange: (value: string) => void;
/** Current custom role text (only relevant when value === CUSTOM_ROLE). */
customRole?: string;
/** Called when the user types a custom role. */
onCustomRoleChange?: (customRole: string) => void;
/** Trigger height class, e.g. "h-7" or "h-8". */
triggerClassName?: string;
/** Custom input height class. */
inputClassName?: string;
/** Show validation error for custom role. */
customRoleError?: string | null;
/** Validate custom role on change and return error or null. */
onCustomRoleValidate?: (role: string) => string | null;
disabled?: boolean;
}
const roleOptions: ComboboxOption[] = [
{ value: NO_ROLE, label: 'No role' },
...PRESET_ROLES.map((role) => ({
value: role,
label: role,
})),
{ value: CUSTOM_ROLE, label: 'Custom role...' },
];
const renderRoleOption = (option: ComboboxOption, isSelected: boolean): React.ReactNode => {
const Icon =
option.value === CUSTOM_ROLE
? CUSTOM_ICON
: option.value === NO_ROLE
? null
: ROLE_ICONS[option.value] ?? null;
return (
<>
<span className="mr-2 flex size-4 shrink-0 items-center justify-center">
{isSelected ? (
<Check className="size-3.5" />
) : Icon ? (
<Icon className="size-3.5 text-[var(--color-text-muted)]" />
) : null}
</span>
<span className="min-w-0 truncate font-medium text-[var(--color-text)]">{option.label}</span>
</>
);
};
export const RoleSelect = ({
value,
onValueChange,
customRole = '',
onCustomRoleChange,
triggerClassName,
inputClassName,
customRoleError: externalError,
onCustomRoleValidate,
disabled,
}: RoleSelectProps): React.JSX.Element => {
const [internalError, setInternalError] = useState<string | null>(null);
const error = externalError ?? internalError;
const handleValueChange = useCallback(
(newValue: string) => {
onValueChange(newValue);
if (newValue !== CUSTOM_ROLE) {
setInternalError(null);
}
},
[onValueChange]
);
const handleCustomChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
onCustomRoleChange?.(val);
if (onCustomRoleValidate) {
setInternalError(onCustomRoleValidate(val));
} else if (FORBIDDEN_ROLES.has(val.trim().toLowerCase())) {
setInternalError('This role is reserved');
} else {
setInternalError(null);
}
},
[onCustomRoleChange, onCustomRoleValidate]
);
const selectedLabel = useMemo(() => {
const opt = roleOptions.find((o) => o.value === value);
return opt?.label;
}, [value]);
return (
<div className="space-y-1">
<Combobox
options={roleOptions}
value={value}
onValueChange={handleValueChange}
placeholder={selectedLabel ?? 'No role'}
searchPlaceholder="Search roles..."
emptyMessage="No roles found."
disabled={disabled}
className={triggerClassName}
renderOption={renderRoleOption}
/>
{value === CUSTOM_ROLE && onCustomRoleChange ? (
<div>
<Input
className={inputClassName ?? 'h-8 text-xs'}
value={customRole}
onChange={handleCustomChange}
placeholder="Enter custom role..."
autoFocus
/>
{error ? <span className="mt-0.5 block text-[10px] text-red-400">{error}</span> : null}
</div>
) : null}
</div>
);
};

View file

@ -0,0 +1,125 @@
import { useMemo } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import type { TeamTaskWithKanban } from '@shared/types';
/**
* Status/kanban-column display colors.
* Matches the kanban column palette from KanbanBoard.tsx.
*/
const STATUS_COLORS: Record<string, { text: string; bg: string }> = {
pending: { text: '#60a5fa', bg: 'rgba(59, 130, 246, 0.15)' }, // blue
todo: { text: '#60a5fa', bg: 'rgba(59, 130, 246, 0.15)' },
in_progress: { text: '#facc15', bg: 'rgba(234, 179, 8, 0.15)' }, // yellow
completed: { text: '#4ade80', bg: 'rgba(34, 197, 94, 0.15)' }, // green
done: { text: '#4ade80', bg: 'rgba(34, 197, 94, 0.15)' },
review: { text: '#a78bfa', bg: 'rgba(139, 92, 246, 0.15)' }, // purple
approved: { text: '#34d399', bg: 'rgba(34, 197, 94, 0.25)' }, // bright green
deleted: { text: '#f87171', bg: 'rgba(239, 68, 68, 0.15)' }, // red
};
function getEffectiveColumn(task: TeamTaskWithKanban): string {
if (task.kanbanColumn) return task.kanbanColumn;
if (task.status === 'pending') return 'todo';
if (task.status === 'completed') return 'done';
return task.status;
}
function getStatusLabel(column: string): string {
const labels: Record<string, string> = {
todo: 'To Do',
pending: 'To Do',
in_progress: 'In Progress',
done: 'Done',
completed: 'Done',
review: 'Review',
approved: 'Approved',
deleted: 'Deleted',
};
return labels[column] ?? column;
}
interface TaskTooltipProps {
/** The task ID (number string, e.g. "10"). */
taskId: string;
/** Rendered trigger element. */
children: React.ReactElement;
/** Tooltip placement. */
side?: 'top' | 'bottom' | 'left' | 'right';
}
/**
* Tooltip that shows task summary on hover over any #taskId link.
* Reads task data from the current team in the store.
*/
export const TaskTooltip = ({
taskId,
children,
side = 'top',
}: TaskTooltipProps): React.JSX.Element => {
const tasks = useStore((s) => s.selectedTeamData?.tasks);
const members = useStore((s) => s.selectedTeamData?.members);
const task = useMemo(
() => tasks?.find((t) => t.id === taskId),
[tasks, taskId]
);
const colorMap = useMemo(
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
[members]
);
// If task not found, render children without tooltip
if (!task) return children;
const column = getEffectiveColumn(task);
const statusColor = STATUS_COLORS[column] ?? STATUS_COLORS.pending;
const label = getStatusLabel(column);
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
side={side}
className="max-w-xs space-y-1.5 p-2.5"
>
{/* Subject */}
<div className="text-xs font-medium text-[var(--color-text)]">
<span className="text-[var(--color-text-muted)]">#{taskId}</span>{' '}
{task.subject}
</div>
{/* Status badge */}
<div className="flex items-center gap-2">
<span
className="inline-block rounded px-1.5 py-0.5 text-[10px] font-medium"
style={{ color: statusColor.text, backgroundColor: statusColor.bg }}
>
{label}
</span>
{/* Owner */}
{task.owner ? (
<MemberBadge
name={task.owner}
color={colorMap.get(task.owner)}
/>
) : null}
</div>
{/* Description — full markdown with scroll */}
{task.description ? (
<div className="max-h-[200px] overflow-y-auto text-[10px]">
<MarkdownViewer content={task.description} maxHeight="max-h-none" bare />
</div>
) : null}
</TooltipContent>
</Tooltip>
);
};

View file

@ -181,6 +181,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
const [kanbanFilter, setKanbanFilter] = useState<KanbanFilterState>({ const [kanbanFilter, setKanbanFilter] = useState<KanbanFilterState>({
sessionId: null, sessionId: null,
selectedOwners: new Set(), selectedOwners: new Set(),
columns: new Set(),
}); });
const { const {
@ -1221,7 +1222,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
<div className="flex w-36 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1"> <div className="flex w-36 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
<Search size={12} className="shrink-0 text-[var(--color-text-muted)]" /> <Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
<input <input
type="search" type="text"
placeholder="Search..." placeholder="Search..."
value={messagesSearchQuery} value={messagesSearchQuery}
onChange={(e) => setMessagesSearchQuery(e.target.value)} onChange={(e) => setMessagesSearchQuery(e.target.value)}
@ -1229,6 +1230,15 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none" className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
/> />
{messagesSearchQuery && (
<button
type="button"
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={() => setMessagesSearchQuery('')}
>
<X size={14} />
</button>
)}
</div> </div>
<MessagesFilterPopover <MessagesFilterPopover
filter={messagesFilter} filter={messagesFilter}
@ -1411,13 +1421,15 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
open={addMemberDialogOpen} open={addMemberDialogOpen}
teamName={teamName} teamName={teamName}
existingNames={data.members.map((m) => m.name)} existingNames={data.members.map((m) => m.name)}
existingMembers={data.members}
projectPath={data.config.projectPath}
adding={addingMemberLoading} adding={addingMemberLoading}
onClose={() => setAddMemberDialogOpen(false)} onClose={() => setAddMemberDialogOpen(false)}
onAdd={(name, role) => { onAdd={(name, role, workflow) => {
setAddingMemberLoading(true); setAddingMemberLoading(true);
void (async () => { void (async () => {
try { try {
await addMember(teamName, { name, role }); await addMember(teamName, { name, role, workflow });
setAddMemberDialogOpen(false); setAddMemberDialogOpen(false);
} catch { } catch {
// error shown via store // error shown via store

View file

@ -3,6 +3,8 @@ import { useMemo, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay'; import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay';
import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { TaskTooltip } from '@renderer/components/team/TaskTooltip';
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { import {
CARD_BG, CARD_BG,
@ -175,24 +177,25 @@ function linkifyMentionsInMarkdown(text: string, memberColorMap: Map<string, str
}); });
} }
/** Render `#<digits>` in plain text as clickable inline elements. */ /** Render `#<digits>` in plain text as clickable inline elements with TaskTooltip. */
function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] { function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] {
return text.split(/(#\d+)/g).map((part, i) => { return text.split(/(#\d+)/g).map((part, i) => {
const match = /^#(\d+)$/.exec(part); const match = /^#(\d+)$/.exec(part);
if (!match) return <span key={i}>{part}</span>; if (!match) return <span key={i}>{part}</span>;
const taskId = match[1]; const taskId = match[1];
return ( return (
<button <TaskTooltip key={i} taskId={taskId}>
key={i} <button
type="button" type="button"
className="cursor-pointer font-medium text-blue-400 hover:underline" className="cursor-pointer font-medium text-blue-400 hover:underline"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onClick(taskId); onClick(taskId);
}} }}
> >
{part} {part}
</button> </button>
</TaskTooltip>
); );
}); });
} }
@ -233,25 +236,30 @@ export const ActivityItem = ({
const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null; const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null;
const [isExpanded, setIsExpanded] = useState(!systemLabel); const [isExpanded, setIsExpanded] = useState(!systemLabel);
// Strip agent-only blocks from displayed text + linkify task IDs + @mentions // Strip agent-only blocks + normalize escape sequences (before linkification)
const displayText = useMemo(() => { const strippedText = useMemo(() => {
if (structured) return null; if (structured) return null;
const stripped = stripAgentBlocks(message.text).trim(); const stripped = stripAgentBlocks(message.text).trim();
if (!stripped) return null; // All content was agent-only blocks → show summary instead if (!stripped) return null; // All content was agent-only blocks → show summary instead
// Normalize literal \n from CLI tools (teamctl.js) to real newlines // Normalize literal \n from CLI tools (teamctl.js) to real newlines
const normalized = stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
let result = normalized; }, [structured, message.text]);
if (onTaskIdClick) result = linkifyTaskIdsInMarkdown(result);
// Parse reply BEFORE linkification — linkifyMentionsInMarkdown transforms @name
// into markdown links which breaks the reply regex matcher
const parsedReply = useMemo(
() => (strippedText ? parseMessageReply(strippedText) : null),
[strippedText]
);
// Linkify task IDs (always, for TaskTooltip) + @mentions for display
const displayText = useMemo(() => {
if (!strippedText) return null;
let result = linkifyTaskIdsInMarkdown(strippedText);
if (memberColorMap && memberColorMap.size > 0) if (memberColorMap && memberColorMap.size > 0)
result = linkifyMentionsInMarkdown(result, memberColorMap); result = linkifyMentionsInMarkdown(result, memberColorMap);
return result; return result;
}, [structured, message.text, onTaskIdClick, memberColorMap]); }, [strippedText, memberColorMap]);
// Check if this is a reply message
const parsedReply = useMemo(
() => (displayText ? parseMessageReply(displayText) : null),
[displayText]
);
const rawSummary = const rawSummary =
message.summary || (structured ? getStructuredMessageSummary(structured) : '') || ''; message.summary || (structured ? getStructuredMessageSummary(structured) : '') || '';
@ -276,11 +284,13 @@ export const ActivityItem = ({
}; };
const isHeaderClickable = Boolean(systemLabel); const isHeaderClickable = Boolean(systemLabel);
const isUserSent = message.source === 'user_sent';
return ( return (
<article <article
className="group overflow-hidden rounded-md" className="group rounded-md [overflow:clip]"
style={{ style={{
marginLeft: isUserSent ? 15 : undefined,
backgroundColor: backgroundColor:
rateLimited || isApiError rateLimited || isApiError
? 'var(--tool-result-error-bg)' ? 'var(--tool-result-error-bg)'
@ -467,27 +477,29 @@ export const ActivityItem = ({
</details> </details>
</div> </div>
) : parsedReply ? ( ) : parsedReply ? (
<ReplyQuoteBlock reply={parsedReply} /> <ReplyQuoteBlock reply={parsedReply} memberColor={memberColorMap?.get(parsedReply.agentName)} />
) : displayText ? ( ) : displayText ? (
<span <ExpandableContent>
onClickCapture={ <span
onTaskIdClick onClickCapture={
? (e) => { onTaskIdClick
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>( ? (e) => {
'a[href^="task://"]' const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
); 'a[href^="task://"]'
if (link) { );
e.preventDefault(); if (link) {
e.stopPropagation(); e.preventDefault();
const taskId = link.getAttribute('href')?.replace('task://', ''); e.stopPropagation();
if (taskId) onTaskIdClick(taskId); const taskId = link.getAttribute('href')?.replace('task://', '');
if (taskId) onTaskIdClick(taskId);
}
} }
} : undefined
: undefined }
} >
> <MarkdownViewer content={displayText} maxHeight="max-h-none" copyable bare />
<MarkdownViewer content={displayText} maxHeight="max-h-56" copyable bare /> </span>
</span> </ExpandableContent>
) : summaryText ? ( ) : summaryText ? (
<p className="text-xs italic" style={{ color: CARD_TEXT_LIGHT }}> <p className="text-xs italic" style={{ color: CARD_TEXT_LIGHT }}>
{summaryText} {summaryText}

View file

@ -1,29 +1,71 @@
import { useMemo, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import type { ParsedMessageReply } from '@renderer/utils/agentMessageFormatting'; import type { ParsedMessageReply } from '@renderer/utils/agentMessageFormatting';
interface ReplyQuoteBlockProps { interface ReplyQuoteBlockProps {
reply: ParsedMessageReply; reply: ParsedMessageReply;
/** Color name for the quoted agent (resolved from memberColorMap). */
memberColor?: string;
/** When set, limits height of the reply body (e.g. "max-h-56"). Omit to show full content. */ /** When set, limits height of the reply body (e.g. "max-h-56"). Omit to show full content. */
bodyMaxHeight?: string; bodyMaxHeight?: string;
} }
/** Threshold (characters) above which the "more/less" toggle is shown. */
const LONG_QUOTE_THRESHOLD = 200;
export const ReplyQuoteBlock = ({ export const ReplyQuoteBlock = ({
reply, reply,
memberColor,
bodyMaxHeight = 'max-h-56', bodyMaxHeight = 'max-h-56',
}: ReplyQuoteBlockProps): React.JSX.Element => ( }: ReplyQuoteBlockProps): React.JSX.Element => {
<div className="space-y-2"> const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD;
<div const [expanded, setExpanded] = useState(false);
className="rounded-md border-l-2 border-[var(--color-border-emphasis)] bg-[var(--color-surface)] px-3 py-2"
style={{ opacity: 0.7 }} const quoteMaxHeight = expanded ? 'max-h-48' : 'max-h-[3.75rem]';
>
<span className="mb-0.5 block text-[10px] font-medium text-[var(--color-text-muted)]"> return (
@{reply.agentName} <div className="space-y-2">
</span> {/* Quote block — styled like SendMessageDialog */}
<div className="line-clamp-3 text-xs text-[var(--color-text-muted)]"> <div className="relative overflow-hidden rounded-md border border-blue-500/20 bg-blue-950/20 py-2 pl-3 pr-2">
<MarkdownViewer content={reply.originalText} maxHeight="max-h-[60px]" bare /> {/* Decorative quotation mark */}
<span className="pointer-events-none absolute -right-1 top-1/2 -translate-y-1/2 select-none font-serif text-[48px] leading-none text-blue-400/[0.08]">
&ldquo;
</span>
{/* "Replying to" + MemberBadge */}
<div className="mb-1 flex items-center gap-1.5">
<span className="text-[10px] text-blue-300/60">Replying to</span>
<MemberBadge name={reply.agentName} color={memberColor} size="sm" />
</div>
{/* Quote text */}
<div
className={`pr-5 opacity-50 ${expanded ? '' : 'max-h-[3.75rem] overflow-hidden'}`}
>
<MarkdownViewer
content={reply.originalText}
bare
maxHeight={quoteMaxHeight}
/>
</div>
{/* More/less toggle */}
{isLong ? (
<button
type="button"
className="mt-0.5 text-[10px] text-blue-400/60 hover:text-blue-300"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? 'less' : 'more'}
</button>
) : null}
</div> </div>
{/* Reply text */}
<MarkdownViewer content={reply.replyText} maxHeight={bodyMaxHeight} copyable bare />
</div> </div>
<MarkdownViewer content={reply.replyText} maxHeight={bodyMaxHeight} copyable bare /> );
</div> };
);

View file

@ -1,5 +1,5 @@
import { formatFileSize } from '@renderer/utils/attachmentUtils'; import { formatFileSize } from '@renderer/utils/attachmentUtils';
import { X } from 'lucide-react'; import { Ban, X } from 'lucide-react';
import { AttachmentThumbnail } from './AttachmentThumbnail'; import { AttachmentThumbnail } from './AttachmentThumbnail';
@ -8,16 +8,23 @@ import type { AttachmentPayload } from '@shared/types';
interface AttachmentPreviewItemProps { interface AttachmentPreviewItemProps {
attachment: AttachmentPayload; attachment: AttachmentPayload;
onRemove: (id: string) => void; onRemove: (id: string) => void;
disabled?: boolean;
} }
export const AttachmentPreviewItem = ({ export const AttachmentPreviewItem = ({
attachment, attachment,
onRemove, onRemove,
disabled,
}: AttachmentPreviewItemProps): React.JSX.Element => { }: AttachmentPreviewItemProps): React.JSX.Element => {
const dataUrl = `data:${attachment.mimeType};base64,${attachment.data}`; const dataUrl = `data:${attachment.mimeType};base64,${attachment.data}`;
return ( return (
<div className="group/att relative flex shrink-0 items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-1.5"> <div className="group/att relative flex shrink-0 items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-1.5">
{disabled ? (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/50">
<Ban size={18} className="text-red-400" />
</div>
) : null}
<AttachmentThumbnail src={dataUrl} alt={attachment.filename} size="sm" /> <AttachmentThumbnail src={dataUrl} alt={attachment.filename} size="sm" />
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
<span className="max-w-[100px] truncate text-[11px] text-[var(--color-text-secondary)]"> <span className="max-w-[100px] truncate text-[11px] text-[var(--color-text-secondary)]">

View file

@ -8,12 +8,18 @@ interface AttachmentPreviewListProps {
attachments: AttachmentPayload[]; attachments: AttachmentPayload[];
onRemove: (id: string) => void; onRemove: (id: string) => void;
error?: string | null; error?: string | null;
/** When true, previews are overlaid with a disabled indicator (recipient doesn't support attachments). */
disabled?: boolean;
/** Hint text shown when disabled and attachments are present. */
disabledHint?: string;
} }
export const AttachmentPreviewList = ({ export const AttachmentPreviewList = ({
attachments, attachments,
onRemove, onRemove,
error, error,
disabled,
disabledHint,
}: AttachmentPreviewListProps): React.JSX.Element | null => { }: AttachmentPreviewListProps): React.JSX.Element | null => {
if (attachments.length === 0 && !error) return null; if (attachments.length === 0 && !error) return null;
@ -22,10 +28,21 @@ export const AttachmentPreviewList = ({
{attachments.length > 0 ? ( {attachments.length > 0 ? (
<div className="flex gap-2 overflow-x-auto py-1"> <div className="flex gap-2 overflow-x-auto py-1">
{attachments.map((att) => ( {attachments.map((att) => (
<AttachmentPreviewItem key={att.id} attachment={att} onRemove={onRemove} /> <AttachmentPreviewItem
key={att.id}
attachment={att}
onRemove={onRemove}
disabled={disabled}
/>
))} ))}
</div> </div>
) : null} ) : null}
{disabled && disabledHint && attachments.length > 0 ? (
<div className="flex items-center gap-1.5 rounded-md bg-amber-500/10 px-2.5 py-1.5">
<AlertCircle size={13} className="shrink-0 text-amber-400" />
<p className="text-[11px] text-amber-400">{disabledHint}</p>
</div>
) : null}
{error ? ( {error ? (
<div className="flex items-center gap-1.5 rounded-md bg-red-500/10 px-2.5 py-1.5"> <div className="flex items-center gap-1.5 rounded-md bg-red-500/10 px-2.5 py-1.5">
<AlertCircle size={13} className="shrink-0 text-red-400" /> <AlertCircle size={13} className="shrink-0 text-red-400" />

View file

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { import {
@ -11,16 +11,16 @@ 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 { import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
Select, import { RoleSelect } from '@renderer/components/team/RoleSelect';
SelectContent, import { CUSTOM_ROLE, NO_ROLE } from '@renderer/constants/teamRoles';
SelectItem, import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
SelectTrigger, import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
SelectValue,
} from '@renderer/components/ui/select';
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember } from '@shared/types';
const NAME_REGEX = /^[a-z0-9][a-z0-9-]*$/; const NAME_REGEX = /^[a-z0-9][a-z0-9-]*$/;
interface AddMemberDialogProps { interface AddMemberDialogProps {
@ -28,8 +28,12 @@ interface AddMemberDialogProps {
teamName: string; teamName: string;
existingNames: string[]; existingNames: string[];
onClose: () => void; onClose: () => void;
onAdd: (name: string, role?: string) => void; onAdd: (name: string, role?: string, workflow?: string) => void;
adding?: boolean; adding?: boolean;
/** Project path for @file mentions in workflow field. */
projectPath?: string | null;
/** Existing team members for @mention suggestions. */
existingMembers?: ResolvedTeamMember[];
} }
export const AddMemberDialog = ({ export const AddMemberDialog = ({
@ -39,12 +43,36 @@ export const AddMemberDialog = ({
onClose, onClose,
onAdd, onAdd,
adding, adding,
projectPath,
existingMembers = [],
}: AddMemberDialogProps): React.JSX.Element => { }: AddMemberDialogProps): React.JSX.Element => {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [roleSelect, setRoleSelect] = useState<string>(NO_ROLE); const [roleSelect, setRoleSelect] = useState<string>(NO_ROLE);
const [customRole, setCustomRole] = useState(''); const [customRole, setCustomRole] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const draftKey = `addMember:${teamName}:workflow`;
const workflowDraft = useDraftPersistence({
key: draftKey,
enabled: open,
});
// Pre-warm file list cache for @file mentions
useFileListCacheWarmer(open && projectPath ? projectPath : null);
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>
existingMembers
.filter((m) => !m.removedAt)
.map((m) => ({
id: m.name,
name: m.name,
subtitle: m.role ?? undefined,
color: m.color,
})),
[existingMembers]
);
const effectiveRole = const effectiveRole =
roleSelect === CUSTOM_ROLE roleSelect === CUSTOM_ROLE
? customRole.trim() ? customRole.trim()
@ -72,7 +100,9 @@ export const AddMemberDialog = ({
return; return;
} }
setError(null); setError(null);
onAdd(name.trim().toLowerCase(), effectiveRole); const wf = workflowDraft.value.trim() || undefined;
onAdd(name.trim().toLowerCase(), effectiveRole, wf);
workflowDraft.clearDraft();
}; };
const handleOpenChange = (nextOpen: boolean): void => { const handleOpenChange = (nextOpen: boolean): void => {
@ -80,11 +110,20 @@ export const AddMemberDialog = ({
setName(''); setName('');
setRoleSelect(NO_ROLE); setRoleSelect(NO_ROLE);
setCustomRole(''); setCustomRole('');
workflowDraft.setValue('');
workflowDraft.clearDraft();
setError(null); setError(null);
onClose(); onClose();
} }
}; };
const handleWorkflowChange = useCallback(
(v: string) => {
workflowDraft.setValue(v);
},
[workflowDraft]
);
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
@ -113,27 +152,31 @@ export const AddMemberDialog = ({
<div className="space-y-2"> <div className="space-y-2">
<Label className="label-optional">Role (optional)</Label> <Label className="label-optional">Role (optional)</Label>
<Select value={roleSelect} onValueChange={setRoleSelect}> <RoleSelect
<SelectTrigger> value={roleSelect}
<SelectValue placeholder="No role" /> onValueChange={setRoleSelect}
</SelectTrigger> customRole={customRole}
<SelectContent> onCustomRoleChange={setCustomRole}
<SelectItem value={NO_ROLE}>No role</SelectItem> />
{PRESET_ROLES.map((role) => ( </div>
<SelectItem key={role} value={role}>
{role} <div className="space-y-2">
</SelectItem> <Label className="label-optional">Workflow (optional)</Label>
))} <MentionableTextarea
<SelectItem value={CUSTOM_ROLE}>Custom...</SelectItem> className="text-xs"
</SelectContent> minRows={3}
</Select> maxRows={8}
{roleSelect === CUSTOM_ROLE && ( value={workflowDraft.value}
<Input onValueChange={handleWorkflowChange}
placeholder="Custom role" suggestions={mentionSuggestions}
value={customRole} projectPath={projectPath ?? undefined}
onChange={(e) => setCustomRole(e.target.value)} placeholder="How this agent should behave, what tasks it handles. Use @ to mention teammates or add files."
/> footerRight={
)} workflowDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null
}
/>
</div> </div>
</div> </div>

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
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';
@ -89,28 +89,40 @@ export const CreateTaskDialog = ({
const promptDraft = useDraftPersistence({ key: `createTask:${teamName}:prompt` }); const promptDraft = useDraftPersistence({ key: `createTask:${teamName}:prompt` });
const [blockedBySearch, setBlockedBySearch] = useState(''); const [blockedBySearch, setBlockedBySearch] = useState('');
const [relatedSearch, setRelatedSearch] = useState(''); const [relatedSearch, setRelatedSearch] = useState('');
const [prevOpen, setPrevOpen] = useState(false); const prevOpenRef = useRef(false);
if (open && !prevOpen) { // Reset form when dialog opens (avoid setState during render)
setSubject(defaultSubject); useEffect(() => {
if (defaultChip) { if (open && !prevOpenRef.current) {
const token = chipToken(defaultChip); setSubject(defaultSubject);
descriptionDraft.setValue(token + '\n'); if (defaultChip) {
descChipDraft.setChips([defaultChip]); const token = chipToken(defaultChip);
} else if (defaultDescription) { descriptionDraft.setValue(token + '\n');
descriptionDraft.setValue(defaultDescription); descChipDraft.setChips([defaultChip]);
} else if (defaultDescription) {
descriptionDraft.setValue(defaultDescription);
}
setOwner(defaultOwner);
setBlockedBy([]);
setRelated([]);
setStartImmediately(defaultStartImmediately ?? isTeamAlive);
promptDraft.clearDraft();
setBlockedBySearch('');
setRelatedSearch('');
} }
setOwner(defaultOwner); prevOpenRef.current = open;
setBlockedBy([]); }, [
setRelated([]); open,
setStartImmediately(defaultStartImmediately ?? isTeamAlive); defaultSubject,
promptDraft.clearDraft(); defaultDescription,
setBlockedBySearch(''); defaultOwner,
setRelatedSearch(''); defaultStartImmediately,
} defaultChip,
if (open !== prevOpen) { isTeamAlive,
setPrevOpen(open); descriptionDraft,
} descChipDraft,
promptDraft,
]);
const mentionSuggestions = useMemo<MentionSuggestion[]>( const mentionSuggestions = useMemo<MentionSuggestion[]>(
() => () =>

View file

@ -15,13 +15,7 @@ import {
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 { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { import { Combobox, type ComboboxOption } from '@renderer/components/ui/combobox';
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
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 { getTeamColorSet } from '@renderer/constants/teamColors';
import { useAttachments } from '@renderer/hooks/useAttachments'; import { useAttachments } from '@renderer/hooks/useAttachments';
@ -33,7 +27,7 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; 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 { ImagePlus, X } from 'lucide-react'; import { Check, ImagePlus, X } from 'lucide-react';
import { MemberBadge } from '../MemberBadge'; import { MemberBadge } from '../MemberBadge';
@ -69,8 +63,6 @@ interface SendMessageDialogProps {
onClose: () => void; onClose: () => void;
} }
const NO_MEMBER = '__none__';
export const SendMessageDialog = ({ export const SendMessageDialog = ({
open, open,
teamName, teamName,
@ -87,6 +79,15 @@ 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);
@ -94,8 +95,8 @@ export const SendMessageDialog = ({
const textDraft = useDraftPersistence({ key: 'sendMessage:text' }); const textDraft = useDraftPersistence({ key: 'sendMessage:text' });
const chipDraft = useChipDraftPersistence('sendMessage:chips'); const chipDraft = useChipDraftPersistence('sendMessage:chips');
const [summary, setSummary] = useState(''); const [summary, setSummary] = useState('');
const [prevOpen, setPrevOpen] = useState(false); const prevOpenRef = useRef(false);
const [prevResult, setPrevResult] = useState<SendMessageResult | null>(null); const prevResultRef = useRef<SendMessageResult | null>(null);
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const dragCounterRef = useRef(0); const dragCounterRef = useRef(0);
@ -116,33 +117,36 @@ export const SendMessageDialog = ({
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead'; const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
const canAttach = isLeadRecipient && isTeamAlive && canAddMore; const canAttach = isLeadRecipient && isTeamAlive && canAddMore;
// Reset form when dialog opens
if (open && !prevOpen) {
setMember(defaultRecipient ?? '');
setSummary('');
setQuote(quotedMessage);
setQuoteExpanded(false);
setPrevResult(lastResult);
if (defaultChip) {
const token = chipToken(defaultChip);
textDraft.setValue(token + '\n');
chipDraft.setChips([defaultChip]);
} else if (defaultText) {
textDraft.setValue(defaultText);
}
}
if (open !== prevOpen) {
setPrevOpen(open);
}
// Track whether auto-close is needed (setState in render phase is fine)
const [pendingAutoClose, setPendingAutoClose] = useState(false); const [pendingAutoClose, setPendingAutoClose] = useState(false);
if (open && lastResult && lastResult !== prevResult) { // Reset form on open transition (avoid setState in render)
setPrevResult(lastResult); useEffect(() => {
setMember(''); if (open && !prevOpenRef.current) {
setSummary(''); setMember(defaultRecipient ?? '');
setPendingAutoClose(true); setSummary('');
} setQuote(quotedMessage);
setQuoteExpanded(false);
prevResultRef.current = lastResult;
if (defaultChip) {
const token = chipToken(defaultChip);
textDraft.setValue(token + '\n');
chipDraft.setChips([defaultChip]);
} else if (defaultText) {
textDraft.setValue(defaultText);
}
}
prevOpenRef.current = open;
}, [open, defaultRecipient, defaultText, defaultChip, quotedMessage, lastResult, textDraft, chipDraft]);
// Track whether auto-close is needed (avoid setState in render)
useEffect(() => {
if (!open) return;
if (lastResult && lastResult !== prevResultRef.current) {
prevResultRef.current = lastResult;
setMember('');
setSummary('');
setPendingAutoClose(true);
}
}, [open, lastResult]);
// Side effects (onClose mutates parent state) must run in useEffect, not render phase // Side effects (onClose mutates parent state) must run in useEffect, not render phase
useEffect(() => { useEffect(() => {
@ -170,11 +174,14 @@ export const SendMessageDialog = ({
[members, colorMap] [members, colorMap]
); );
const attachmentsBlocked = attachments.length > 0 && !isLeadRecipient;
const canSend = const canSend =
member.trim().length > 0 && member.trim().length > 0 &&
textDraft.value.trim().length > 0 && textDraft.value.trim().length > 0 &&
summary.trim().length > 0 && summary.trim().length > 0 &&
!sending; !sending &&
!attachmentsBlocked;
const handleChipRemove = (chipId: string): void => { const handleChipRemove = (chipId: string): void => {
const chip = chipDraft.chips.find((c) => c.id === chipId); const chip = chipDraft.chips.find((c) => c.id === chipId);
@ -271,40 +278,44 @@ 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>
<Select <Combobox
value={member || NO_MEMBER} value={member}
onValueChange={(v) => setMember(v === NO_MEMBER ? '' : v)} onValueChange={setMember}
> placeholder="Select member..."
<SelectTrigger id="smd-recipient"> searchPlaceholder="Search members..."
<SelectValue placeholder="Select member..." /> emptyMessage="No members found."
</SelectTrigger> options={recipientOptions}
<SelectContent> renderOption={(option, isSelected) => {
<SelectItem value={NO_MEMBER}>Select member...</SelectItem> const resolvedColor = colorMap.get(option.value);
{members.map((m) => { const optionColorSet = resolvedColor ? getTeamColorSet(resolvedColor) : null;
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); return (
const resolvedColor = colorMap.get(m.name); <>
const memberColor = resolvedColor ? getTeamColorSet(resolvedColor) : null; {optionColorSet ? (
return ( <span
<SelectItem key={m.name} value={m.name}> className="mr-2 inline-block size-2 shrink-0 rounded-full"
<span className="inline-flex items-center gap-1.5"> style={{ backgroundColor: optionColorSet.border }}
{memberColor ? ( />
<span ) : (
className="inline-block size-2 shrink-0 rounded-full" <span className="mr-2 inline-block size-2 shrink-0 rounded-full bg-[var(--color-text-muted)]" />
style={{ backgroundColor: memberColor.border }} )}
/> <span
) : null} className="min-w-0 truncate font-medium"
<span style={memberColor ? { color: memberColor.text } : undefined}> style={optionColorSet ? { color: optionColorSet.text } : undefined}
{m.name} >
</span> {option.label}
{role ? ( </span>
<span className="text-[var(--color-text-muted)]">({role})</span> {option.description ? (
) : null} <span className="ml-1 shrink-0 text-[10px] text-[var(--color-text-muted)]">
{option.description}
</span> </span>
</SelectItem> ) : null}
); {isSelected ? (
})} <Check size={12} className="ml-auto shrink-0 text-blue-400" />
</SelectContent> ) : null}
</Select> </>
);
}}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
@ -351,6 +362,8 @@ export const SendMessageDialog = ({
attachments={attachments} attachments={attachments}
onRemove={removeAttachment} onRemove={removeAttachment}
error={attachmentError} error={attachmentError}
disabled={attachmentsBlocked}
disabledHint="Image attachments are only supported when sending to team lead. Remove attachments or switch recipient."
/> />
<div className={quote ? 'flex flex-col' : 'contents'}> <div className={quote ? 'flex flex-col' : 'contents'}>
@ -409,6 +422,7 @@ export const SendMessageDialog = ({
onChipRemove={handleChipRemove} onChipRemove={handleChipRemove}
projectPath={projectPath} projectPath={projectPath}
onFileChipInsert={(chip) => chipDraft.setChips([...chipDraft.chips, chip])} onFileChipInsert={(chip) => chipDraft.setChips([...chipDraft.chips, chip])}
onModEnter={handleSubmit}
minRows={4} minRows={4}
maxRows={12} maxRows={12}
footerRight={ footerRight={

View file

@ -9,7 +9,7 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { ImagePlus, Send, Trash2, X } from 'lucide-react'; import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types'; import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types';
@ -86,10 +86,6 @@ export const TaskCommentInput = ({
); );
continue; continue;
} }
if (pendingAttachments.length >= MAX_ATTACHMENTS) {
setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`);
break;
}
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
const result = reader.result as string; const result = reader.result as string;
@ -97,7 +93,10 @@ export const TaskCommentInput = ({
if (!base64) return; if (!base64) return;
const id = crypto.randomUUID(); const id = crypto.randomUUID();
setPendingAttachments((prev) => { setPendingAttachments((prev) => {
if (prev.length >= MAX_ATTACHMENTS) return prev; if (prev.length >= MAX_ATTACHMENTS) {
setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`);
return prev;
}
return [ return [
...prev, ...prev,
{ {
@ -114,7 +113,7 @@ export const TaskCommentInput = ({
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
}, },
[pendingAttachments.length] []
); );
const removeAttachment = useCallback((id: string) => { const removeAttachment = useCallback((id: string) => {
@ -256,6 +255,7 @@ export const TaskCommentInput = ({
onValueChange={draft.setValue} onValueChange={draft.setValue}
suggestions={mentionSuggestions} suggestions={mentionSuggestions}
projectPath={projectPath} projectPath={projectPath}
onModEnter={() => void handleSubmit()}
minRows={2} minRows={2}
maxRows={8} maxRows={8}
maxLength={MAX_COMMENT_LENGTH} maxLength={MAX_COMMENT_LENGTH}
@ -275,6 +275,18 @@ export const TaskCommentInput = ({
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top">Attach image (or paste)</TooltipContent> <TooltipContent side="top">Attach image (or paste)</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex shrink-0 items-center rounded-full p-1.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-secondary)]"
onClick={() => void window.electronAPI.openExternal('https://voicetext.site')}
>
<Mic size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="top">Voice to text</TooltipContent>
</Tooltip>
<button <button
type="button" type="button"
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50" className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"

View file

@ -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 { 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';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
@ -14,25 +15,10 @@ import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { import { CheckCircle2, Eye, Loader2, MessageSquare, Reply, Send, X } from 'lucide-react';
CheckCircle2,
ChevronDown,
ChevronUp,
Eye,
Loader2,
MessageSquare,
Reply,
Send,
X,
} from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
import type { import type { ResolvedTeamMember, TaskAttachmentMeta, TaskComment } from '@shared/types';
AttachmentMediaType,
ResolvedTeamMember,
TaskAttachmentMeta,
TaskComment,
} from '@shared/types';
/** /**
* Convert literal backslash-n sequences to real newlines. * Convert literal backslash-n sequences to real newlines.
@ -61,6 +47,8 @@ interface TaskCommentsSectionProps {
onReply?: (author: string, text: string) => void; onReply?: (author: string, text: string) => void;
/** Called when a task ID link (e.g. #10) is clicked in comment text. */ /** Called when a task ID link (e.g. #10) is clicked in comment text. */
onTaskIdClick?: (taskId: string) => void; onTaskIdClick?: (taskId: string) => void;
/** Extra className on the outer comments container (e.g. negative margins for edge-to-edge). */
containerClassName?: string;
} }
/** Convert `#<digits>` in plain text to markdown links with task:// protocol. */ /** Convert `#<digits>` in plain text to markdown links with task:// protocol. */
@ -74,7 +62,7 @@ function linkifyMentionsInMarkdown(text: string, memberColorMap: Map<string, str
const names = [...memberColorMap.keys()].sort((a, b) => b.length - a.length); const names = [...memberColorMap.keys()].sort((a, b) => b.length - a.length);
const escaped = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); const escaped = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const pattern = new RegExp(`(^|\\s)@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}-]|$)`, 'gi'); const pattern = new RegExp(`(^|\\s)@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}-]|$)`, 'gi');
return text.replace(pattern, (match, prefix: string, name: string) => { return text.replace(pattern, (_match, prefix: string, name: string) => {
const canonical = names.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name; const canonical = names.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name;
const color = memberColorMap.get(canonical) ?? ''; const color = memberColorMap.get(canonical) ?? '';
return `${prefix}[@${canonical}](mention://${encodeURIComponent(color)}/${encodeURIComponent(canonical)})`; return `${prefix}[@${canonical}](mention://${encodeURIComponent(color)}/${encodeURIComponent(canonical)})`;
@ -90,35 +78,22 @@ export const TaskCommentsSection = ({
hideInput = false, hideInput = false,
onReply, onReply,
onTaskIdClick, onTaskIdClick,
containerClassName,
}: TaskCommentsSectionProps): React.JSX.Element => { }: TaskCommentsSectionProps): React.JSX.Element => {
const addTaskComment = useStore((s) => s.addTaskComment); const addTaskComment = useStore((s) => s.addTaskComment);
const addingComment = useStore((s) => s.addingComment); const addingComment = useStore((s) => s.addingComment);
const commentsRef = useMarkCommentsRead(teamName, taskId, comments); const commentsRef = useMarkCommentsRead(teamName, taskId, comments);
const [replyTo, setReplyTo] = useState<{ author: string; text: string } | null>(null); const [replyTo, setReplyTo] = useState<{ author: string; text: string } | null>(null);
const [expandedCommentIds, setExpandedCommentIds] = useState<Set<string>>(new Set());
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS); const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS);
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null); const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null);
// Reset local state when team/task changes (React-recommended pattern for // Reset local UI state when team/task changes.
// adjusting state based on props without using effects or refs during render) useEffect(() => {
const currentKey = teamIdKey(teamName, taskId);
const [prevKey, setPrevKey] = useState(currentKey);
if (prevKey !== currentKey) {
setPrevKey(currentKey);
setVisibleCount(INITIAL_VISIBLE_COMMENTS); setVisibleCount(INITIAL_VISIBLE_COMMENTS);
setExpandedCommentIds(new Set());
setReplyTo(null); setReplyTo(null);
} setPreviewImageUrl(null);
}, [teamName, taskId]);
const toggleCommentExpanded = useCallback((commentId: string) => {
setExpandedCommentIds((prev) => {
const next = new Set(prev);
if (next.has(commentId)) next.delete(commentId);
else next.add(commentId);
return next;
});
}, []);
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` }); const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
@ -183,28 +158,29 @@ export const TaskCommentsSection = ({
) : null} ) : null}
{comments.length > 0 ? ( {comments.length > 0 ? (
<div className="mb-3 space-y-2"> <div className="mb-3">
{comments.length > MAX_COMMENTS_TO_RENDER ? ( {comments.length > MAX_COMMENTS_TO_RENDER ? (
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2 text-[11px] text-[var(--color-text-muted)]"> <div className="mb-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2 text-[11px] text-[var(--color-text-muted)]">
Showing the most recent {MAX_COMMENTS_TO_RENDER.toLocaleString()} comments to keep the Showing the most recent {MAX_COMMENTS_TO_RENDER.toLocaleString()} comments to keep the
UI responsive. UI responsive.
</div> </div>
) : null} ) : null}
<div className={containerClassName ?? ''}>
{visibleComments.map((comment, index) => ( {visibleComments.map((comment, index) => (
<div <div
key={comment.id} key={comment.id}
className={[ className={[
'group rounded-md p-2.5', 'group px-4 py-2.5',
comment.type === 'review_approved' comment.type === 'review_approved'
? 'border border-emerald-500/20 bg-emerald-500/5' ? 'border-y border-emerald-500/20 bg-emerald-500/5'
: comment.type === 'review_request' : comment.type === 'review_request'
? 'border border-blue-500/20 bg-blue-500/5' ? 'border-y border-blue-500/20 bg-blue-500/5'
: '', : '',
].join(' ')} ].join(' ')}
style={ style={
!comment.type && index % 2 === 1 !comment.type || comment.type === 'regular'
? { backgroundColor: 'var(--card-bg-zebra)' } ? { backgroundColor: index % 2 === 1 ? 'var(--card-bg-zebra)' : 'var(--card-bg)' }
: undefined : undefined
} }
> >
@ -256,99 +232,48 @@ export const TaskCommentsSection = ({
const reply = parseMessageReply(comment.text); const reply = parseMessageReply(comment.text);
const rawForDisplay = reply ? reply.replyText : comment.text; const rawForDisplay = reply ? reply.replyText : comment.text;
const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay)); const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay));
const needsExpandCollapse = displayText.includes('\n');
const expanded = expandedCommentIds.has(comment.id);
const collapsedHeight = 'max-h-[120px]';
const showCollapsed = needsExpandCollapse && !expanded;
const showExpandedButton = needsExpandCollapse && expanded;
return ( return (
<div className="relative text-xs"> <ExpandableContent collapsedHeight={120} className="text-xs">
<div {reply ? (
className={ <ReplyQuoteBlock
showCollapsed ? `relative ${collapsedHeight} overflow-hidden` : undefined reply={{
} ...reply,
> originalText: stripAgentBlocks(reply.originalText),
{reply ? ( replyText: stripAgentBlocks(reply.replyText),
<ReplyQuoteBlock }}
reply={{ memberColor={colorMap.get(reply.agentName)}
...reply, bodyMaxHeight="max-h-none"
originalText: stripAgentBlocks(reply.originalText), />
replyText: stripAgentBlocks(reply.replyText), ) : (
}} <span
bodyMaxHeight={ onClickCapture={
needsExpandCollapse && !expanded ? 'max-h-56' : 'max-h-none' onTaskIdClick
} ? (e) => {
/> const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
) : ( 'a[href^="task://"]'
<span );
onClickCapture={ if (link) {
onTaskIdClick e.preventDefault();
? (e) => { e.stopPropagation();
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>( const id = link.getAttribute('href')?.replace('task://', '');
'a[href^="task://"]' if (id) onTaskIdClick(id);
);
if (link) {
e.preventDefault();
e.stopPropagation();
const id = link.getAttribute('href')?.replace('task://', '');
if (id) onTaskIdClick(id);
}
} }
: undefined }
} : undefined
> }
<MarkdownViewer >
content={(() => { <MarkdownViewer
let t = displayText; content={(() => {
if (onTaskIdClick) t = linkifyTaskIdsInMarkdown(t); let t = linkifyTaskIdsInMarkdown(displayText);
if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap); if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap);
return t; return t;
})()} })()}
maxHeight={ maxHeight="max-h-none"
needsExpandCollapse && !expanded ? collapsedHeight : 'max-h-none' bare
} />
bare </span>
/>
</span>
)}
{showCollapsed && (
<>
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-14"
style={{
background:
'linear-gradient(to top, var(--color-surface) 0%, transparent 100%)',
}}
aria-hidden
/>
<div className="absolute inset-x-0 bottom-0 flex justify-center pt-1">
<button
type="button"
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-sm transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={() => toggleCommentExpanded(comment.id)}
title="Expand"
>
<ChevronDown size={12} />
Expand
</button>
</div>
</>
)}
</div>
{showExpandedButton && (
<div className="flex justify-center pt-2">
<button
type="button"
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
onClick={() => toggleCommentExpanded(comment.id)}
title="Collapse"
>
<ChevronUp size={12} />
Collapse
</button>
</div>
)} )}
</div> </ExpandableContent>
); );
})()} })()}
{comment.attachments && comment.attachments.length > 0 ? ( {comment.attachments && comment.attachments.length > 0 ? (
@ -361,6 +286,7 @@ export const TaskCommentsSection = ({
) : null} ) : null}
</div> </div>
))} ))}
</div>
{sortedComments.length > visibleComments.length ? ( {sortedComments.length > visibleComments.length ? (
<div className="flex items-center justify-center pt-2"> <div className="flex items-center justify-center pt-2">
@ -431,6 +357,7 @@ export const TaskCommentsSection = ({
value={draft.value} value={draft.value}
onValueChange={draft.setValue} onValueChange={draft.setValue}
suggestions={mentionSuggestions} suggestions={mentionSuggestions}
onModEnter={() => void handleSubmit()}
minRows={2} minRows={2}
maxRows={8} maxRows={8}
maxLength={MAX_COMMENT_LENGTH} maxLength={MAX_COMMENT_LENGTH}
@ -556,7 +483,3 @@ const CommentAttachments = ({
))} ))}
</div> </div>
); );
function teamIdKey(teamName: string, taskId: string): string {
return `${teamName}::${taskId}`;
}

View file

@ -6,6 +6,7 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab'; 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 { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -545,7 +546,7 @@ export const TaskDetailDialog = ({
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
className="group max-h-[200px] cursor-pointer overflow-y-auto" className="group cursor-pointer"
onClick={startEditDescription} onClick={startEditDescription}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@ -554,7 +555,9 @@ export const TaskDetailDialog = ({
} }
}} }}
> >
<MarkdownViewer content={currentTask.description} maxHeight="max-h-[180px]" bare /> <ExpandableContent collapsedHeight={200}>
<MarkdownViewer content={currentTask.description} maxHeight="max-h-none" bare />
</ExpandableContent>
<Pencil <Pencil
size={12} size={12}
className="mt-1 text-[var(--color-text-muted)] opacity-0 transition-opacity group-hover:opacity-100" className="mt-1 text-[var(--color-text-muted)] opacity-0 transition-opacity group-hover:opacity-100"
@ -858,18 +861,20 @@ export const TaskDetailDialog = ({
? (currentTask.comments?.length ?? 0) ? (currentTask.comments?.length ?? 0)
: undefined : undefined
} }
contentClassName="pl-2.5" contentClassName="overflow-x-visible pl-0"
headerClassName="-mx-6 w-[calc(100%+3rem)]" headerClassName="-mx-6 w-[calc(100%+3rem)]"
headerContentClassName="pl-6" headerContentClassName="pl-6"
defaultOpen defaultOpen
> >
<TaskCommentInput <div className="pl-2.5">
teamName={teamName} <TaskCommentInput
taskId={currentTask.id} teamName={teamName}
members={members} taskId={currentTask.id}
replyTo={effectiveReplyTo} members={members}
onClearReply={clearReply} replyTo={effectiveReplyTo}
/> onClearReply={clearReply}
/>
</div>
<TaskCommentsSection <TaskCommentsSection
teamName={teamName} teamName={teamName}
taskId={currentTask.id} taskId={currentTask.id}
@ -879,6 +884,7 @@ export const TaskDetailDialog = ({
hideInput hideInput
onReply={handleReply} onReply={handleReply}
onTaskIdClick={onScrollToTask ? (taskId) => handleDependencyClick(taskId) : undefined} onTaskIdClick={onScrollToTask ? (taskId) => handleDependencyClick(taskId) : undefined}
containerClassName="-mx-6"
/> />
</CollapsibleTeamSection> </CollapsibleTeamSection>

View file

@ -33,15 +33,14 @@ export const EditorImagePreview = ({
const [dimensions, setDimensions] = useState<{ w: number; h: number } | null>(null); const [dimensions, setDimensions] = useState<{ w: number; h: number } | null>(null);
const imgRef = useRef<HTMLImageElement>(null); const imgRef = useRef<HTMLImageElement>(null);
// Reset state when filePath changes (setState-during-render, React-approved pattern) // Reset state when filePath changes
const [prevFilePath, setPrevFilePath] = useState(filePath); useEffect(() => {
if (prevFilePath !== filePath) {
setPrevFilePath(filePath);
setLoading(true); setLoading(true);
setError(null); setError(null);
setDataUrl(null); setDataUrl(null);
setDimensions(null); setDimensions(null);
} setLightboxOpen(false);
}, [filePath]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;

View file

@ -37,18 +37,11 @@ export const QuickOpenDialog = ({
const [allFiles, setAllFiles] = useState<QuickOpenFile[]>([]); const [allFiles, setAllFiles] = useState<QuickOpenFile[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Reset loading state when projectPath changes (React-recommended
// "adjusting state when props change" pattern without effects or refs)
const [prevProjectPath, setPrevProjectPath] = useState(projectPath);
if (prevProjectPath !== projectPath) {
setPrevProjectPath(projectPath);
setLoading(true);
}
// Load all project files on mount via backend API // Load all project files on mount via backend API
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true);
window.electronAPI.editor window.electronAPI.editor
.listFiles() .listFiles()
.then((files) => { .then((files) => {

View file

@ -6,6 +6,7 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useResizableColumns } from '@renderer/hooks/useResizableColumns';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { import {
CheckCircle2, CheckCircle2,
@ -402,6 +403,17 @@ export const KanbanBoard = ({
); );
}; };
const visibleColumns = useMemo(
() => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS),
[filter.columns]
);
const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]);
const { widths: columnWidths, getHandleProps } = useResizableColumns({
storageKey: teamName,
columnIds: resizableColumnIds,
});
const boardContent = ( const boardContent = (
<> <>
<div className={cn('mb-2 flex items-center gap-2', toolbarLeft == null && 'justify-end')}> <div className={cn('mb-2 flex items-center gap-2', toolbarLeft == null && 'justify-end')}>
@ -475,7 +487,7 @@ export const KanbanBoard = ({
{viewMode === 'grid' ? ( {viewMode === 'grid' ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5"> <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
{COLUMNS.map((column) => { {visibleColumns.map((column) => {
const columnTasks = groupedOrdered.get(column.id) ?? []; const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[column.id]; const accent = COLUMN_ACCENTS[column.id];
return ( return (
@ -493,21 +505,32 @@ export const KanbanBoard = ({
})} })}
</div> </div>
) : ( ) : (
<div className="flex gap-3 overflow-x-auto pb-2"> <div className="flex overflow-x-auto pb-2">
{COLUMNS.map((column) => { {visibleColumns.map((column, index) => {
const columnTasks = groupedOrdered.get(column.id) ?? []; const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[column.id]; const accent = COLUMN_ACCENTS[column.id];
const width = columnWidths.get(column.id) ?? 256;
return ( return (
<div key={column.id} className="w-64 shrink-0"> <div key={column.id} className="flex shrink-0">
<KanbanColumn <div style={{ width }}>
title={column.title} <KanbanColumn
count={columnTasks.length} title={column.title}
icon={accent.icon} count={columnTasks.length}
headerBg={accent.headerBg} icon={accent.icon}
bodyBg={accent.bodyBg} headerBg={accent.headerBg}
> bodyBg={accent.bodyBg}
{renderCards(column.id, columnTasks, true)} >
</KanbanColumn> {renderCards(column.id, columnTasks, true)}
</KanbanColumn>
</div>
{index < visibleColumns.length - 1 ? (
<div
className="group relative mx-0.5 flex items-center"
{...getHandleProps(column.id)}
>
<div className="h-full w-px bg-[var(--color-border)] transition-colors group-hover:bg-blue-500/50 group-active:bg-blue-500" />
</div>
) : null}
</div> </div>
); );
})} })}

View file

@ -7,15 +7,26 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
import { Crown, Filter } from 'lucide-react'; import { Crown, Filter } from 'lucide-react';
import type { Session } from '@renderer/types/data'; import type { Session } from '@renderer/types/data';
import type { ResolvedTeamMember } from '@shared/types'; import type { KanbanColumnId, ResolvedTeamMember } from '@shared/types';
export const UNASSIGNED_OWNER = '__unassigned__'; export const UNASSIGNED_OWNER = '__unassigned__';
export interface KanbanFilterState { export interface KanbanFilterState {
sessionId: string | null; sessionId: string | null;
selectedOwners: Set<string>; selectedOwners: Set<string>;
/** When non-empty, only these columns are visible on the kanban board. Empty = all columns. */
columns: Set<KanbanColumnId>;
} }
/** Column definitions with display labels and accent colors for filter UI. */
export const KANBAN_COLUMNS: { id: KanbanColumnId; label: string; color: string }[] = [
{ id: 'todo', label: 'TODO', color: 'rgb(59, 130, 246)' },
{ id: 'in_progress', label: 'IN PROGRESS', color: 'rgb(234, 179, 8)' },
{ id: 'done', label: 'DONE', color: 'rgb(34, 197, 94)' },
{ id: 'review', label: 'REVIEW', color: 'rgb(139, 92, 246)' },
{ id: 'approved', label: 'APPROVED', color: 'rgb(22, 163, 74)' },
];
interface KanbanFilterPopoverProps { interface KanbanFilterPopoverProps {
filter: KanbanFilterState; filter: KanbanFilterState;
sessions: Session[]; sessions: Session[];
@ -35,8 +46,9 @@ export const KanbanFilterPopover = ({
let count = 0; let count = 0;
if (filter.sessionId !== null) count += 1; if (filter.sessionId !== null) count += 1;
if (filter.selectedOwners.size > 0) count += 1; if (filter.selectedOwners.size > 0) count += 1;
if (filter.columns.size > 0) count += 1;
return count; return count;
}, [filter.sessionId, filter.selectedOwners]); }, [filter.sessionId, filter.selectedOwners, filter.columns]);
const handleSessionSelect = (sessionId: string | null): void => { const handleSessionSelect = (sessionId: string | null): void => {
onFilterChange({ ...filter, sessionId }); onFilterChange({ ...filter, sessionId });
@ -52,8 +64,18 @@ export const KanbanFilterPopover = ({
onFilterChange({ ...filter, selectedOwners: next }); onFilterChange({ ...filter, selectedOwners: next });
}; };
const handleColumnToggle = (columnId: KanbanColumnId): void => {
const next = new Set(filter.columns);
if (next.has(columnId)) {
next.delete(columnId);
} else {
next.add(columnId);
}
onFilterChange({ ...filter, columns: next });
};
const handleClearAll = (): void => { const handleClearAll = (): void => {
onFilterChange({ sessionId: null, selectedOwners: new Set() }); onFilterChange({ sessionId: null, selectedOwners: new Set(), columns: new Set() });
}; };
return ( return (
@ -148,6 +170,28 @@ export const KanbanFilterPopover = ({
</div> </div>
</div> </div>
{/* Column section */}
<div className="border-b border-[var(--color-border)] p-3">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
Column
</p>
<div className="space-y-1.5">
{KANBAN_COLUMNS.map((col) => (
<label
key={col.id}
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs hover:bg-[var(--color-surface-raised)]"
style={{ color: col.color }}
>
<Checkbox
checked={filter.columns.has(col.id)}
onCheckedChange={() => handleColumnToggle(col.id)}
/>
{col.label}
</label>
))}
</div>
</div>
{/* Footer */} {/* Footer */}
<div className="flex justify-end p-2"> <div className="flex justify-end p-2">
<Button <Button

View file

@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge'; import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@ -207,6 +206,12 @@ export const KanbanTaskCard = ({
}, [showChangesColumn, task.status, task.id, teamName, taskHasChanges, checkTaskHasChanges]); }, [showChangesColumn, task.status, task.id, teamName, taskHasChanges, checkTaskHasChanges]);
const isReviewManual = columnId === 'review' && !hasReviewers; const isReviewManual = columnId === 'review' && !hasReviewers;
const multiButton =
compact ||
columnId === 'todo' ||
columnId === 'in_progress' ||
columnId === 'done' ||
columnId === 'review';
const metaActions = ( const metaActions = (
<> <>
@ -243,7 +248,7 @@ export const KanbanTaskCard = ({
return ( return (
<div <div
data-task-id={task.id} data-task-id={task.id}
className={`cursor-pointer rounded-md border p-3 transition-colors hover:border-[var(--color-border-emphasis)] ${ className={`relative cursor-pointer rounded-md border p-3 transition-colors hover:border-[var(--color-border-emphasis)] ${
hasBlockedBy hasBlockedBy
? 'border-yellow-500/30 bg-[var(--color-surface-raised)]' ? 'border-yellow-500/30 bg-[var(--color-surface-raised)]'
: 'border-[var(--color-border)] bg-[var(--color-surface-raised)]' : 'border-[var(--color-border)] bg-[var(--color-surface-raised)]'
@ -258,11 +263,11 @@ export const KanbanTaskCard = ({
} }
}} }}
> >
<div className="mb-2"> <span className="absolute left-[3px] top-[2px] text-[9px] leading-none text-[var(--color-text-muted)]">
#{task.id}
</span>
<div className="mb-2 pt-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Badge variant="secondary" className="shrink-0 px-1 py-0 text-[10px] font-normal">
#{task.id}
</Badge>
{task.owner ? <MemberBadge name={task.owner} color={colorMap.get(task.owner)} /> : null} {task.owner ? <MemberBadge name={task.owner} color={colorMap.get(task.owner)} /> : null}
{!compact && <TruncatedTitle text={task.subject} className="min-w-0" />} {!compact && <TruncatedTitle text={task.subject} className="min-w-0" />}
</div> </div>
@ -315,7 +320,7 @@ export const KanbanTaskCard = ({
</div> </div>
) : null} ) : null}
<div className="flex items-end gap-2"> <div className={multiButton ? 'space-y-2' : 'flex items-end gap-2'}>
<div className="flex flex-1 flex-wrap gap-2"> <div className="flex flex-1 flex-wrap gap-2">
{columnId === 'todo' ? ( {columnId === 'todo' ? (
<> <>
@ -448,7 +453,11 @@ export const KanbanTaskCard = ({
) : null} ) : null}
</div> </div>
{!isReviewManual ? <div className="flex items-center gap-1.5">{metaActions}</div> : null} {!isReviewManual ? (
<div className={`flex items-center gap-1.5 ${multiButton ? 'justify-end' : ''}`}>
{metaActions}
</div>
) : null}
</div> </div>
</div> </div>
); );

View file

@ -3,15 +3,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input'; import { Input } from '@renderer/components/ui/input';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { import { RoleSelect } from '@renderer/components/team/RoleSelect';
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
@ -143,33 +136,15 @@ export const MemberDraftRow = ({
/> />
{nameError ? <p className="text-[10px] text-red-300">{nameError}</p> : null} {nameError ? <p className="text-[10px] text-red-300">{nameError}</p> : null}
</div> </div>
<div className="space-y-1"> <div>
<Select <RoleSelect
value={member.roleSelection || NO_ROLE} value={member.roleSelection || '__none__'}
onValueChange={(roleSelection) => onRoleChange(member.id, roleSelection)} onValueChange={(roleSelection) => onRoleChange(member.id, roleSelection)}
> customRole={member.customRole}
<SelectTrigger className="h-8 text-xs" aria-label={`Member ${index + 1} role`}> onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)}
<SelectValue placeholder="No role" /> triggerClassName="h-8 text-xs"
</SelectTrigger> inputClassName="h-8 text-xs"
<SelectContent> />
<SelectItem value={NO_ROLE}>No role</SelectItem>
{PRESET_ROLES.map((role) => (
<SelectItem key={role} value={role}>
{role}
</SelectItem>
))}
<SelectItem value={CUSTOM_ROLE}>Custom role...</SelectItem>
</SelectContent>
</Select>
{member.roleSelection === CUSTOM_ROLE ? (
<Input
className="h-8 text-xs"
value={member.customRole}
aria-label={`Member ${index + 1} custom role`}
onChange={(event) => onCustomRoleChange(member.id, event.target.value)}
placeholder="e.g. architect"
/>
) : null}
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex flex-col gap-2 sm:flex-row">
{showWorkflow && onWorkflowChange ? ( {showWorkflow && onWorkflowChange ? (

View file

@ -1,14 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input'; import { RoleSelect } from '@renderer/components/team/RoleSelect';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { CUSTOM_ROLE, FORBIDDEN_ROLES, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; import { CUSTOM_ROLE, FORBIDDEN_ROLES, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import { Check, Loader2, X } from 'lucide-react'; import { Check, Loader2, X } from 'lucide-react';
@ -32,8 +25,6 @@ export const MemberRoleEditor = ({
const [customInput, setCustomInput] = useState(isPreset ? '' : (currentRole ?? '')); const [customInput, setCustomInput] = useState(isPreset ? '' : (currentRole ?? ''));
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const showCustomInput = selectValue === CUSTOM_ROLE;
const handleSelectChange = (value: string): void => { const handleSelectChange = (value: string): void => {
setSelectValue(value); setSelectValue(value);
setError(null); setError(null);
@ -65,40 +56,22 @@ export const MemberRoleEditor = ({
return ( return (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Select value={selectValue} onValueChange={handleSelectChange}> <RoleSelect
<SelectTrigger className="h-7 w-32 text-xs"> value={selectValue}
<SelectValue /> onValueChange={handleSelectChange}
</SelectTrigger> customRole={customInput}
<SelectContent> onCustomRoleChange={(val) => {
<SelectItem value={NO_ROLE}>No role</SelectItem> setCustomInput(val);
{PRESET_ROLES.map((r) => ( setError(null);
<SelectItem key={r} value={r}> }}
{r} triggerClassName="h-7 w-32 text-xs"
</SelectItem> inputClassName="h-7 w-28 text-xs"
))} customRoleError={error}
<SelectItem value={CUSTOM_ROLE}>Custom...</SelectItem> onCustomRoleValidate={(val) => {
</SelectContent> if (FORBIDDEN_ROLES.has(val.trim().toLowerCase())) return 'This role is reserved';
</Select> return null;
}}
{showCustomInput && ( />
<div className="flex flex-col">
<Input
value={customInput}
onChange={(e) => {
setCustomInput(e.target.value);
setError(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSave();
if (e.key === 'Escape') onCancel();
}}
placeholder="Enter role..."
className="h-7 w-28 text-xs"
autoFocus
/>
{error && <span className="mt-0.5 text-[10px] text-red-400">{error}</span>}
</div>
)}
<Button variant="ghost" size="icon" className="size-6" onClick={handleSave} disabled={saving}> <Button variant="ghost" size="icon" className="size-6" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 size={12} className="animate-spin" /> : <Check size={12} />} {saving ? <Loader2 size={12} className="animate-spin" /> : <Check size={12} />}

View file

@ -2,10 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList'; import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList';
import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay'; import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
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';
@ -15,10 +15,10 @@ import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { AlertCircle, Check, ChevronDown, ImagePlus, Send } from 'lucide-react'; import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
import type { AttachmentPayload, ResolvedTeamMember } from '@shared/types'; import type { AttachmentPayload, LeadContextUsage, ResolvedTeamMember } from '@shared/types';
interface MessageComposerProps { interface MessageComposerProps {
teamName: string; teamName: string;
@ -36,6 +36,55 @@ interface MessageComposerProps {
const MAX_MESSAGE_LENGTH = 4000; const MAX_MESSAGE_LENGTH = 4000;
/** Circular progress indicator for lead context usage. */
const ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => {
const size = 26;
const stroke = 2.5;
const radius = (size - stroke) / 2;
const circumference = 2 * Math.PI * radius;
const pct = Math.min(ctx.percent, 100);
const offset = circumference - (pct / 100) * circumference;
const color = pct > 90 ? '#ef4444' : pct > 70 ? '#f59e0b' : '#3b82f6';
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="relative flex shrink-0 cursor-default items-center justify-center" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="var(--color-border)"
strokeWidth={stroke}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={stroke}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-all duration-500"
/>
</svg>
<span className="absolute text-[8px] font-medium" style={{ color }}>
{Math.round(pct)}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="top">
Context: {Math.round(pct)}% ({(ctx.currentTokens / 1000).toFixed(1)}k /{' '}
{(ctx.contextWindow / 1000).toFixed(0)}k tokens)
</TooltipContent>
</Tooltip>
);
};
export const MessageComposer = ({ export const MessageComposer = ({
teamName, teamName,
members, members,
@ -49,6 +98,8 @@ export const MessageComposer = ({
return lead?.name ?? members[0]?.name ?? ''; return lead?.name ?? members[0]?.name ?? '';
}); });
const [recipientOpen, setRecipientOpen] = useState(false); const [recipientOpen, setRecipientOpen] = useState(false);
const [recipientSearch, setRecipientSearch] = useState('');
const recipientSearchRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const dragCounterRef = useRef(0); const dragCounterRef = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -93,14 +144,22 @@ export const MessageComposer = ({
); );
const trimmed = draft.value.trim(); const trimmed = draft.value.trim();
const canSend =
recipient.length > 0 && trimmed.length > 0 && trimmed.length <= MAX_MESSAGE_LENGTH && !sending;
const selectedMember = members.find((m) => m.name === recipient); const selectedMember = members.find((m) => m.name === recipient);
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined; const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
const selectedColorSet = selectedResolvedColor ? getTeamColorSet(selectedResolvedColor) : null;
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead'; const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead';
const leadContext = useStore((s) =>
isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
);
const canAttach = isLeadRecipient && isTeamAlive && canAddMore; const canAttach = isLeadRecipient && isTeamAlive && canAddMore;
const attachmentsBlocked = attachments.length > 0 && !isLeadRecipient;
const canSend =
recipient.length > 0 &&
trimmed.length > 0 &&
trimmed.length <= MAX_MESSAGE_LENGTH &&
!sending &&
!attachmentsBlocked;
// Track whether we initiated a send — clear draft only on confirmed success // Track whether we initiated a send — clear draft only on confirmed success
const pendingSendRef = useRef(false); const pendingSendRef = useRef(false);
@ -204,68 +263,90 @@ export const MessageComposer = ({
type="button" type="button"
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] px-2.5 py-1 text-xs transition-colors hover:border-[var(--color-border-emphasis)] hover:bg-[var(--color-surface-raised)]" className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] px-2.5 py-1 text-xs transition-colors hover:border-[var(--color-border-emphasis)] hover:bg-[var(--color-surface-raised)]"
> >
{selectedColorSet ? ( {recipient ? (
<span <MemberBadge
className="inline-block size-2 shrink-0 rounded-full" name={recipient}
style={{ backgroundColor: selectedColorSet.border }} color={selectedResolvedColor}
size="sm"
hideAvatar={recipient === 'user'}
/> />
) : ( ) : (
<span className="inline-block size-2 shrink-0 rounded-full bg-[var(--color-text-muted)]" /> <span className="text-[var(--color-text-muted)]">Select...</span>
)} )}
<span
className="max-w-[120px] truncate font-medium"
style={selectedColorSet ? { color: selectedColorSet.text } : undefined}
>
{recipient || 'Select...'}
</span>
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" /> <ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="start" className="w-56 p-1.5"> <PopoverContent
align="start"
className="w-56 p-1.5"
onOpenAutoFocus={(e) => {
e.preventDefault();
setRecipientSearch('');
setTimeout(() => recipientSearchRef.current?.focus(), 0);
}}
>
{members.length > 5 && (
<div className="relative mb-1">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]" />
<input
ref={recipientSearchRef}
type="text"
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] py-1 pl-6 pr-2 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none"
placeholder="Search..."
value={recipientSearch}
onChange={(e) => setRecipientSearch(e.target.value)}
/>
</div>
)}
<div className="max-h-48 space-y-0.5 overflow-y-auto"> <div className="max-h-48 space-y-0.5 overflow-y-auto">
{members.map((m) => { {(() => {
const resolvedColor = colorMap.get(m.name); const query = recipientSearch.toLowerCase().trim();
const colorSet = resolvedColor ? getTeamColorSet(resolvedColor) : null; const filtered = query
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); ? members.filter((m) => m.name.toLowerCase().includes(query))
const isSelected = m.name === recipient; : members;
return ( if (filtered.length === 0) {
<button return (
key={m.name} <div className="px-2 py-3 text-center text-xs text-[var(--color-text-muted)]">
type="button" No results
className={cn( </div>
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]', );
isSelected && 'bg-[var(--color-surface-raised)]' }
)} return filtered.map((m) => {
onClick={() => { const resolvedColor = colorMap.get(m.name);
setRecipient(m.name); const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
setRecipientOpen(false); const isSelected = m.name === recipient;
}} return (
> <button
{colorSet ? ( key={m.name}
<span type="button"
className="inline-block size-2 shrink-0 rounded-full" className={cn(
style={{ backgroundColor: colorSet.border }} 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]',
/> isSelected && 'bg-[var(--color-surface-raised)]'
) : ( )}
<span className="inline-block size-2 shrink-0 rounded-full bg-[var(--color-text-muted)]" /> onClick={() => {
)} setRecipient(m.name);
<span setRecipientOpen(false);
className="min-w-0 truncate font-medium" setRecipientSearch('');
style={colorSet ? { color: colorSet.text } : undefined} }}
> >
{m.name} <MemberBadge
</span> name={m.name}
{role ? ( color={resolvedColor}
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]"> size="sm"
{role} hideAvatar={m.name === 'user'}
</span> />
) : null} {role ? (
{isSelected ? ( <span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
<Check size={12} className="ml-auto shrink-0 text-blue-400" /> {role}
) : null} </span>
</button> ) : null}
); {isSelected ? (
})} <Check size={12} className="ml-auto shrink-0 text-blue-400" />
) : null}
</button>
);
});
})()}
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@ -316,6 +397,8 @@ export const MessageComposer = ({
attachments={attachments} attachments={attachments}
onRemove={removeAttachment} onRemove={removeAttachment}
error={attachmentError} error={attachmentError}
disabled={attachmentsBlocked}
disabledHint="Image attachments are only supported when sending to team lead. Remove attachments or switch recipient."
/> />
<MentionableTextarea <MentionableTextarea
@ -328,20 +411,36 @@ export const MessageComposer = ({
onChipRemove={chipDraft.removeChip} onChipRemove={chipDraft.removeChip}
projectPath={projectPath} projectPath={projectPath}
onFileChipInsert={chipDraft.addChip} onFileChipInsert={chipDraft.addChip}
onModEnter={handleSend}
minRows={2} minRows={2}
maxRows={6} maxRows={6}
maxLength={MAX_MESSAGE_LENGTH} maxLength={MAX_MESSAGE_LENGTH}
disabled={sending} disabled={sending}
cornerAction={ cornerAction={
<button <div className="flex items-center gap-2">
type="button" {leadContext && leadContext.percent > 0 && <ContextRing ctx={leadContext} />}
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50" <Tooltip>
disabled={!canSend} <TooltipTrigger asChild>
onClick={handleSend} <button
> type="button"
<Send size={12} /> className="inline-flex shrink-0 items-center rounded-full p-1.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-secondary)]"
Send onClick={() => void window.electronAPI.openExternal('https://voicetext.site')}
</button> >
<Mic size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="top">Voice to text</TooltipContent>
</Tooltip>
<button
type="button"
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!canSend}
onClick={handleSend}
>
<Send size={12} />
Send
</button>
</div>
} }
footerRight={ footerRight={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View file

@ -1,9 +1,12 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox'; import { Checkbox } from '@renderer/components/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { Filter } from 'lucide-react'; import { Filter } from 'lucide-react';
import type { InboxMessage } from '@shared/types'; import type { InboxMessage } from '@shared/types';
@ -57,6 +60,9 @@ export const MessagesFilterPopover = ({
} }
}, [open, filter.from, filter.to]); }, [open, filter.from, filter.to]);
const members = useStore((s) => s.selectedTeamData?.members ?? []);
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const fromOptions = useMemo(() => collectFromOptions(messages), [messages]); const fromOptions = useMemo(() => collectFromOptions(messages), [messages]);
const toOptions = useMemo(() => collectToOptions(messages), [messages]); const toOptions = useMemo(() => collectToOptions(messages), [messages]);
@ -132,7 +138,7 @@ export const MessagesFilterPopover = ({
checked={draft.from.has(name)} checked={draft.from.has(name)}
onCheckedChange={() => toggleFrom(name)} onCheckedChange={() => toggleFrom(name)}
/> />
{name} <MemberBadge name={name} color={colorMap.get(name)} size="sm" hideAvatar={name === 'user'} />
</label> </label>
)) ))
)} )}
@ -152,7 +158,7 @@ export const MessagesFilterPopover = ({
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]" className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
> >
<Checkbox checked={draft.to.has(name)} onCheckedChange={() => toggleTo(name)} /> <Checkbox checked={draft.to.has(name)} onCheckedChange={() => toggleTo(name)} />
{name} <MemberBadge name={name} color={colorMap.get(name)} size="sm" hideAvatar={name === 'user'} />
</label> </label>
)) ))
)} )}

View file

@ -724,14 +724,12 @@ export const ChangeReviewDialog = ({
}); });
}, [activeChangeSet, initialFilePath, scrollToFile]); }, [activeChangeSet, initialFilePath, scrollToFile]);
// Clear selection state on close (React-approved setState-during-render pattern) // Clear selection state on close
const [prevOpen, setPrevOpen] = useState(open); useEffect(() => {
if (prevOpen !== open) {
setPrevOpen(open);
if (!open) { if (!open) {
setSelectionInfo(null); setSelectionInfo(null);
} }
} }, [open]);
// Cleanup refs/timers on close // Cleanup refs/timers on close
useEffect(() => { useEffect(() => {

View file

@ -0,0 +1,108 @@
import { useCallback, useRef, useState } from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
const DEFAULT_COLLAPSED_HEIGHT = 200; // px
interface ExpandableContentProps {
/** Content to render inside the expandable container. */
children: React.ReactNode;
/** Maximum height (px) before truncation kicks in. Default: 200. */
collapsedHeight?: number;
/** Extra className applied to the outermost wrapper. */
className?: string;
}
/**
* Generic expand/collapse wrapper with:
* - Collapsed: content clipped at `collapsedHeight`, mask-image fade, "Show more" button
* - Expanded: full content, sticky "Show less" button at viewport bottom
*
* Uses CSS `mask-image` for the fade so it works on any background color
* (zebra stripes, card backgrounds, etc.) without needing to know the bg color.
*/
export const ExpandableContent = ({
children,
collapsedHeight = DEFAULT_COLLAPSED_HEIGHT,
className,
}: ExpandableContentProps): React.JSX.Element => {
const anchorRef = useRef<HTMLDivElement>(null);
const [expanded, setExpanded] = useState(false);
const [needsTruncation, setNeedsTruncation] = useState(false);
// Measure content height via callback ref — re-runs when children change
const measureRef = useCallback(
(node: HTMLDivElement | null) => {
if (node) {
requestAnimationFrame(() => {
setNeedsTruncation(node.scrollHeight > collapsedHeight);
});
}
},
// Re-measure when children identity changes (content prop in callers)
// eslint-disable-next-line react-hooks/exhaustive-deps
[children, collapsedHeight]
);
const handleCollapse = useCallback(() => {
setExpanded(false);
anchorRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}, []);
return (
<div ref={anchorRef} className={className}>
<div
ref={measureRef}
className="relative"
style={
!expanded && needsTruncation
? {
maxHeight: collapsedHeight,
overflow: 'hidden',
WebkitMaskImage:
'linear-gradient(to bottom, black 60%, transparent 100%)',
maskImage:
'linear-gradient(to bottom, black 60%, transparent 100%)',
}
: undefined
}
>
{children}
</div>
{/* Show more */}
{!expanded && needsTruncation ? (
<div className="flex justify-center pt-1">
<button
type="button"
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-sm transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
setExpanded(true);
}}
>
<ChevronDown size={12} />
Show more
</button>
</div>
) : null}
{/* Sticky Show less */}
{expanded && needsTruncation ? (
<div className="sticky bottom-0 z-10 flex justify-center pb-1 pt-2">
<button
type="button"
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] shadow-sm transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
onClick={(e) => {
e.stopPropagation();
handleCollapse();
}}
>
<ChevronUp size={12} />
Show less
</button>
</div>
) : null}
</div>
);
};

View file

@ -204,6 +204,8 @@ interface MentionableTextareaProps extends Omit<
projectPath?: string | null; projectPath?: string | null;
/** Called when a file chip is created via @ selection. Parent must add chip to state. */ /** Called when a file chip is created via @ selection. Parent must add chip to state. */
onFileChipInsert?: (chip: InlineChip) => void; onFileChipInsert?: (chip: InlineChip) => void;
/** Called when Cmd+Enter (Mac) / Ctrl+Enter (Win/Linux) is pressed. */
onModEnter?: () => void;
} }
export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, MentionableTextareaProps>( export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, MentionableTextareaProps>(
@ -220,6 +222,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
onChipRemove, onChipRemove,
projectPath, projectPath,
onFileChipInsert, onFileChipInsert,
onModEnter,
style, style,
className, className,
...textareaProps ...textareaProps
@ -497,9 +500,15 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
[isOpen, allSuggestions, mergedIndex, handleMergedSelect, dismiss] [isOpen, allSuggestions, mergedIndex, handleMergedSelect, dismiss]
); );
// Composed key handler: chip logic → (file-aware OR original) mention logic // Composed key handler: Mod+Enter submit → chip logic → mention logic
const composedHandleKeyDown = React.useCallback( const composedHandleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => { (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Mod+Enter (Cmd on Mac, Ctrl on Win/Linux) → submit
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && onModEnter) {
e.preventDefault();
onModEnter();
return;
}
handleChipKeyDown(e); handleChipKeyDown(e);
if (!e.defaultPrevented) { if (!e.defaultPrevented) {
if (enableFiles) { if (enableFiles) {
@ -509,7 +518,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
} }
} }
}, },
[handleChipKeyDown, enableFiles, fileMentionHandleKeyDown, mentionHandleKeyDown] [onModEnter, handleChipKeyDown, enableFiles, fileMentionHandleKeyDown, mentionHandleKeyDown]
); );
// --- Chip reconciliation on text change --- // --- Chip reconciliation on text change ---

View file

@ -110,6 +110,9 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
if (!persistenceKey) return; if (!persistenceKey) return;
let cancelled = false; let cancelled = false;
// Clear stale attachments from previous persistenceKey before loading
attachmentsRef.current = [];
setAttachments([]);
void (async () => { void (async () => {
const raw = await draftStorage.loadDraft(persistenceKey); const raw = await draftStorage.loadDraft(persistenceKey);
if (cancelled || raw == null) return; if (cancelled || raw == null) return;

View file

@ -5,7 +5,7 @@
* Returns up to 8 matching files filtered by name or relative path. * Returns up to 8 matching files filtered by name or relative path.
*/ */
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
getQuickOpenCache, getQuickOpenCache,
@ -72,17 +72,15 @@ export function useFileSuggestions(
// Bumped on cache invalidation (file create/delete) to trigger refetch // Bumped on cache invalidation (file create/delete) to trigger refetch
const [fetchTrigger, setFetchTrigger] = useState(0); const [fetchTrigger, setFetchTrigger] = useState(0);
// Re-seed from cache when projectPath changes (setState-during-render pattern) // Re-seed from cache when projectPath changes
const [prevPath, setPrevPath] = useState(projectPath); useEffect(() => {
if (prevPath !== projectPath) { if (!projectPath) {
setPrevPath(projectPath);
const cached = projectPath ? getQuickOpenCache(projectPath) : null;
if (cached) {
setAllFiles(cached.files);
} else {
setAllFiles([]); setAllFiles([]);
return;
} }
} const cached = getQuickOpenCache(projectPath);
setAllFiles(cached?.files ?? []);
}, [projectPath]);
// React to cache invalidation from EditorFileWatcher (create/delete events) // React to cache invalidation from EditorFileWatcher (create/delete events)
useEffect(() => { useEffect(() => {
@ -90,13 +88,13 @@ export function useFileSuggestions(
}, []); }, []);
// Lazy refetch: when dropdown opens and cache is stale, trigger a reload // Lazy refetch: when dropdown opens and cache is stale, trigger a reload
const [prevEnabled, setPrevEnabled] = useState(enabled); const prevEnabledRef = useRef(enabled);
if (enabled && !prevEnabled && projectPath && !getQuickOpenCache(projectPath)) { useEffect(() => {
setFetchTrigger((n) => n + 1); if (enabled && !prevEnabledRef.current && projectPath && !getQuickOpenCache(projectPath)) {
} setFetchTrigger((n) => n + 1);
if (prevEnabled !== enabled) { }
setPrevEnabled(enabled); prevEnabledRef.current = enabled;
} }, [enabled, projectPath]);
// Load files from API when cache is empty. // Load files from API when cache is empty.
// Uses project:listFiles (not editor:listFiles) — works without editor being open. // Uses project:listFiles (not editor:listFiles) — works without editor being open.
@ -126,7 +124,7 @@ export function useFileSuggestions(
// Fetch only when cache is empty. Cache seeding is handled by: // Fetch only when cache is empty. Cache seeding is handled by:
// - lazy initializer (first mount) // - lazy initializer (first mount)
// - setState-during-render (projectPath change) // - effect (projectPath change)
useEffect(() => { useEffect(() => {
if (!projectPath) return; if (!projectPath) return;

View file

@ -0,0 +1,125 @@
import { useCallback, useEffect, useRef, useState } from 'react';
const STORAGE_PREFIX = 'kanban-column-widths:';
const MIN_COLUMN_WIDTH = 180;
const DEFAULT_COLUMN_WIDTH = 256; // w-64
interface UseResizableColumnsOptions {
/** Storage key suffix (e.g. teamName). */
storageKey: string;
/** Column IDs in display order. */
columnIds: string[];
}
interface UseResizableColumnsResult {
/** Width in px for each column ID. */
widths: Map<string, number>;
/** Props to spread on the drag handle between columns. */
getHandleProps: (leftColumnId: string) => {
onPointerDown: (e: React.PointerEvent) => void;
style: React.CSSProperties;
'aria-label': string;
};
}
function loadWidths(key: string): Record<string, number> {
try {
const raw = localStorage.getItem(STORAGE_PREFIX + key);
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {};
const result: Record<string, number> = {};
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'number' && v >= MIN_COLUMN_WIDTH) {
result[k] = v;
}
}
return result;
} catch {
return {};
}
}
function saveWidths(key: string, widths: Record<string, number>): void {
try {
localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(widths));
} catch {
// Quota exceeded — ignore
}
}
export function useResizableColumns({
storageKey,
columnIds,
}: UseResizableColumnsOptions): UseResizableColumnsResult {
const [widthRecord, setWidthRecord] = useState<Record<string, number>>(() =>
loadWidths(storageKey)
);
// Re-read from localStorage when storageKey changes
useEffect(() => {
setWidthRecord(loadWidths(storageKey));
}, [storageKey]);
const draggingRef = useRef<{
leftId: string;
startX: number;
startWidth: number;
} | null>(null);
const widths = new Map<string, number>();
for (const id of columnIds) {
widths.set(id, widthRecord[id] ?? DEFAULT_COLUMN_WIDTH);
}
const handlePointerMove = useCallback((e: PointerEvent) => {
const drag = draggingRef.current;
if (!drag) return;
const delta = e.clientX - drag.startX;
const newWidth = Math.max(MIN_COLUMN_WIDTH, drag.startWidth + delta);
setWidthRecord((prev) => ({ ...prev, [drag.leftId]: newWidth }));
}, []);
const handlePointerUp = useCallback(() => {
const drag = draggingRef.current;
if (!drag) return;
draggingRef.current = null;
document.removeEventListener('pointermove', handlePointerMove);
document.removeEventListener('pointerup', handlePointerUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
// Persist
setWidthRecord((current) => {
saveWidths(storageKey, current);
return current;
});
}, [handlePointerMove, storageKey]);
const getHandleProps = useCallback(
(leftColumnId: string) => ({
onPointerDown: (e: React.PointerEvent) => {
e.preventDefault();
const currentWidth = widthRecord[leftColumnId] ?? DEFAULT_COLUMN_WIDTH;
draggingRef.current = {
leftId: leftColumnId,
startX: e.clientX,
startWidth: currentWidth,
};
document.addEventListener('pointermove', handlePointerMove);
document.addEventListener('pointerup', handlePointerUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
},
style: {
cursor: 'col-resize' as const,
width: 8,
flexShrink: 0,
alignSelf: 'stretch' as const,
},
'aria-label': `Resize column ${leftColumnId}`,
}),
[widthRecord, handlePointerMove, handlePointerUp]
);
return { widths, getHandleProps };
}

View file

@ -15,20 +15,30 @@ export interface ParsedMessageReply {
const REPLY_BLOCK_RE = new RegExp( const REPLY_BLOCK_RE = new RegExp(
'```' + '```' +
MESSAGE_REPLY_TAG + MESSAGE_REPLY_TAG +
'\\nReply on @([\\w-]+) original message with text "([\\s\\S]*?)", here is answer: "([\\s\\S]*?)"\\n```' '\\nReply on @([\\w.-]+) original message with text "([\\s\\S]*?)", here is answer: "([\\s\\S]*?)"\\n```'
); );
function encodeReplyField(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function decodeReplyField(value: string): string {
// Backwards-compat: avoid touching legacy content that has no escapes.
if (!value.includes('\\"') && !value.includes('\\\\')) return value;
return value.replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
/** /**
* Parses a message_reply_for_agent block from content. * Parses a message_reply_for_agent block from content.
* Returns null if no reply block is found. * Returns null if no reply block is found.
*/ */
export function parseMessageReply(content: string): ParsedMessageReply | null { export function parseMessageReply(content: string): ParsedMessageReply | null {
const match = REPLY_BLOCK_RE.exec(content); const match = content.match(REPLY_BLOCK_RE);
if (!match) return null; if (!match) return null;
return { return {
agentName: match[1], agentName: match[1],
originalText: match[2], originalText: decodeReplyField(match[2]),
replyText: match[3], replyText: decodeReplyField(match[3]),
}; };
} }
@ -43,7 +53,7 @@ export function buildReplyBlock(
const tag = MESSAGE_REPLY_TAG; const tag = MESSAGE_REPLY_TAG;
return [ return [
'```' + tag, '```' + tag,
`Reply on @${agentName} original message with text "${originalText}", here is answer: "${replyText}"`, `Reply on @${agentName} original message with text "${encodeReplyField(originalText)}", here is answer: "${encodeReplyField(replyText)}"`,
'```', '```',
].join('\n'); ].join('\n');
} }

View file

@ -444,6 +444,7 @@ export interface MemberFullStats {
export interface AddMemberRequest { export interface AddMemberRequest {
name: string; name: string;
role?: string; role?: string;
workflow?: string;
} }
export interface RemoveMemberRequest { export interface RemoveMemberRequest {

View file

@ -46,6 +46,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({
TEAM_GET_ATTACHMENTS: 'team:getAttachments', TEAM_GET_ATTACHMENTS: 'team:getAttachments',
TEAM_KILL_PROCESS: 'team:killProcess', TEAM_KILL_PROCESS: 'team:killProcess',
TEAM_LEAD_ACTIVITY: 'team:leadActivity', TEAM_LEAD_ACTIVITY: 'team:leadActivity',
TEAM_LEAD_CONTEXT: 'team:leadContext',
TEAM_SOFT_DELETE_TASK: 'team:softDeleteTask', TEAM_SOFT_DELETE_TASK: 'team:softDeleteTask',
TEAM_GET_DELETED_TASKS: 'team:getDeletedTasks', TEAM_GET_DELETED_TASKS: 'team:getDeletedTasks',
TEAM_SET_TASK_CLARIFICATION: 'team:setTaskClarification', TEAM_SET_TASK_CLARIFICATION: 'team:setTaskClarification',
@ -102,6 +103,14 @@ import {
TEAM_ADD_TASK_RELATIONSHIP, TEAM_ADD_TASK_RELATIONSHIP,
TEAM_REMOVE_TASK_RELATIONSHIP, TEAM_REMOVE_TASK_RELATIONSHIP,
TEAM_REPLACE_MEMBERS, TEAM_REPLACE_MEMBERS,
TEAM_UPDATE_TASK_OWNER,
TEAM_UPDATE_TASK_FIELDS,
TEAM_LEAD_CONTEXT,
TEAM_RESTORE_TASK,
TEAM_SHOW_MESSAGE_NOTIFICATION,
TEAM_SAVE_TASK_ATTACHMENT,
TEAM_GET_TASK_ATTACHMENT,
TEAM_DELETE_TASK_ATTACHMENT,
} from '../../../src/preload/constants/ipcChannels'; } from '../../../src/preload/constants/ipcChannels';
import { import {
initializeTeamHandlers, initializeTeamHandlers,
@ -231,6 +240,17 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_PERMANENTLY_DELETE)).toBe(true); expect(handlers.has(TEAM_PERMANENTLY_DELETE)).toBe(true);
expect(handlers.has(TEAM_ADD_TASK_RELATIONSHIP)).toBe(true); expect(handlers.has(TEAM_ADD_TASK_RELATIONSHIP)).toBe(true);
expect(handlers.has(TEAM_REMOVE_TASK_RELATIONSHIP)).toBe(true); expect(handlers.has(TEAM_REMOVE_TASK_RELATIONSHIP)).toBe(true);
expect(handlers.has(TEAM_UPDATE_TASK_OWNER)).toBe(true);
expect(handlers.has(TEAM_UPDATE_TASK_FIELDS)).toBe(true);
expect(handlers.has(TEAM_REPLACE_MEMBERS)).toBe(true);
expect(handlers.has(TEAM_GET_PROJECT_BRANCH)).toBe(true);
expect(handlers.has(TEAM_GET_ATTACHMENTS)).toBe(true);
expect(handlers.has(TEAM_LEAD_CONTEXT)).toBe(true);
expect(handlers.has(TEAM_RESTORE_TASK)).toBe(true);
expect(handlers.has(TEAM_SHOW_MESSAGE_NOTIFICATION)).toBe(true);
expect(handlers.has(TEAM_SAVE_TASK_ATTACHMENT)).toBe(true);
expect(handlers.has(TEAM_GET_TASK_ATTACHMENT)).toBe(true);
expect(handlers.has(TEAM_DELETE_TASK_ATTACHMENT)).toBe(true);
}); });
it('returns success false on invalid sendMessage args', async () => { it('returns success false on invalid sendMessage args', async () => {
@ -552,6 +572,15 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_PERMANENTLY_DELETE)).toBe(false); expect(handlers.has(TEAM_PERMANENTLY_DELETE)).toBe(false);
expect(handlers.has(TEAM_ADD_TASK_RELATIONSHIP)).toBe(false); expect(handlers.has(TEAM_ADD_TASK_RELATIONSHIP)).toBe(false);
expect(handlers.has(TEAM_REMOVE_TASK_RELATIONSHIP)).toBe(false); expect(handlers.has(TEAM_REMOVE_TASK_RELATIONSHIP)).toBe(false);
expect(handlers.has(TEAM_UPDATE_TASK_OWNER)).toBe(false);
expect(handlers.has(TEAM_UPDATE_TASK_FIELDS)).toBe(false);
expect(handlers.has(TEAM_REPLACE_MEMBERS)).toBe(false);
expect(handlers.has(TEAM_LEAD_CONTEXT)).toBe(false);
expect(handlers.has(TEAM_RESTORE_TASK)).toBe(false);
expect(handlers.has(TEAM_SHOW_MESSAGE_NOTIFICATION)).toBe(false);
expect(handlers.has(TEAM_SAVE_TASK_ATTACHMENT)).toBe(false);
expect(handlers.has(TEAM_GET_TASK_ATTACHMENT)).toBe(false);
expect(handlers.has(TEAM_DELETE_TASK_ATTACHMENT)).toBe(false);
}); });
describe('addTaskRelationship', () => { describe('addTaskRelationship', () => {

View file

@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService'; import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
import type { TeamTask } from '../../../../src/shared/types/team'; import type { InboxMessage, TeamTask } from '../../../../src/shared/types/team';
describe('TeamDataService', () => { describe('TeamDataService', () => {
it('runs kanban garbage-collect only after tasks are loaded', async () => { it('runs kanban garbage-collect only after tasks are loaded', async () => {
@ -47,6 +47,72 @@ describe('TeamDataService', () => {
expect(order).toEqual(['tasks', 'gc']); expect(order).toEqual(['tasks', 'gc']);
}); });
it('does not sync automated comment notifications into task comments', async () => {
const tasks: TeamTask[] = [
{
id: '12',
subject: 'Task',
status: 'pending',
},
];
const addComment = vi.fn(async () => {
throw new Error('Should not be called');
});
const messages: InboxMessage[] = [
{
from: 'team-lead',
to: 'alice',
summary: 'Comment on #12',
messageId: 'm1',
timestamp: new Date().toISOString(),
read: false,
text:
'Comment on task #12 "Task":\n\nHello\n\n' +
'<agent-block>\n' +
'Reply to this comment using:\n' +
'node "tool.js" --team my-team task comment 12 --text "..." --from "alice"\n' +
'</agent-block>',
},
];
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({ name: 'My team', members: [{ name: 'team-lead', role: 'Lead' }] })),
} as never,
{
getTasks: vi.fn(async () => tasks),
} as never,
{
listInboxNames: vi.fn(async () => []),
getMessages: vi.fn(async () => messages),
} as never,
{} as never,
{
addComment,
} as never,
{
resolveMembers: vi.fn(() => []),
} as never,
{
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
garbageCollect: vi.fn(async () => undefined),
} as never,
{} as never,
{
readMembers: vi.fn(async () => []),
} as never,
{
readMessages: vi.fn(async () => []),
} as never
);
await service.getTeamData('my-team');
expect(addComment).not.toHaveBeenCalled();
});
it('skips kanban garbage-collect when tasks fail to load', async () => { it('skips kanban garbage-collect when tasks fail to load', async () => {
const garbageCollect = vi.fn(async () => undefined); const garbageCollect = vi.fn(async () => undefined);
const service = new TeamDataService( const service = new TeamDataService(