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:
parent
43d2953874
commit
b163892d20
45 changed files with 1562 additions and 609 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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') {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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('..') ||
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
158
src/renderer/components/team/RoleSelect.tsx
Normal file
158
src/renderer/components/team/RoleSelect.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
125
src/renderer/components/team/TaskTooltip.tsx
Normal file
125
src/renderer/components/team/TaskTooltip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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]">
|
||||||
|
“
|
||||||
|
</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>
|
};
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -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)]">
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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[]>(
|
||||||
() =>
|
() =>
|
||||||
|
|
|
||||||
|
|
@ -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={
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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} />}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
108
src/renderer/components/ui/ExpandableContent.tsx
Normal file
108
src/renderer/components/ui/ExpandableContent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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 ---
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
125
src/renderer/hooks/useResizableColumns.ts
Normal file
125
src/renderer/hooks/useResizableColumns.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue