Merge branch agent_teams_features into main

This commit is contained in:
iliya 2026-02-23 14:57:10 +02:00
commit 41717c5c7e
17 changed files with 609 additions and 281 deletions

View file

@ -18,7 +18,7 @@
}, },
"main": "dist-electron/main/index.cjs", "main": "dist-electron/main/index.cjs",
"scripts": { "scripts": {
"dev": "trap 'kill 0' INT; electron-vite dev", "dev": "electron-vite dev",
"dev:kill": "pkill -f 'electron-vite|electron \\.' 2>/dev/null; echo 'Done'", "dev:kill": "pkill -f 'electron-vite|electron \\.' 2>/dev/null; echo 'Done'",
"build": "electron-vite build", "build": "electron-vite build",
"dist": "electron-builder --mac --win --linux", "dist": "electron-builder --mac --win --linux",
@ -186,12 +186,10 @@
"perMachine": false, "perMachine": false,
"allowToChangeInstallationDirectory": true "allowToChangeInstallationDirectory": true
}, },
"publish": [ "publish": [{
{
"provider": "github", "provider": "github",
"releaseType": "draft" "releaseType": "draft"
} }]
]
}, },
"packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501" "packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501"
} }

View file

@ -769,7 +769,8 @@ async function handleGetMemberLogs(
async function handleGetLogsForTask( async function handleGetLogsForTask(
_event: IpcMainInvokeEvent, _event: IpcMainInvokeEvent,
teamName: unknown, teamName: unknown,
taskId: unknown taskId: unknown,
options?: { owner?: string; status?: string }
): Promise<IpcResult<MemberLogSummary[]>> { ): Promise<IpcResult<MemberLogSummary[]>> {
const vTeam = validateTeamName(teamName); const vTeam = validateTeamName(teamName);
if (!vTeam.valid) { if (!vTeam.valid) {
@ -779,8 +780,15 @@ async function handleGetLogsForTask(
if (!vTask.valid) { if (!vTask.valid) {
return { success: false, error: vTask.error ?? 'Invalid taskId' }; return { success: false, error: vTask.error ?? 'Invalid taskId' };
} }
const opts =
options && typeof options === 'object'
? {
owner: typeof options.owner === 'string' ? options.owner : undefined,
status: typeof options.status === 'string' ? options.status : undefined,
}
: undefined;
return wrapTeamHandler('getLogsForTask', () => return wrapTeamHandler('getLogsForTask', () =>
getTeamMemberLogsFinder().findLogsForTask(vTeam.value!, vTask.value!) getTeamMemberLogsFinder().findLogsForTask(vTeam.value!, vTask.value!, opts)
); );
} }

View file

@ -104,8 +104,14 @@ export class TeamMemberLogsFinder {
/** /**
* Returns session logs that reference the given task (TaskCreate, TaskUpdate, comments, etc.). * Returns session logs that reference the given task (TaskCreate, TaskUpdate, comments, etc.).
* When the task is in_progress and has an owner, also includes that owner's session logs so
* the executor's current activity is visible even before the JSONL mentions the task id.
*/ */
async findLogsForTask(teamName: string, taskId: string): Promise<MemberLogSummary[]> { async findLogsForTask(
teamName: string,
taskId: string,
options?: { owner?: string; status?: string }
): Promise<MemberLogSummary[]> {
const discovery = await this.discoverProjectSessions(teamName); const discovery = await this.discoverProjectSessions(teamName);
if (!discovery) return []; if (!discovery) return [];
@ -159,6 +165,32 @@ export class TeamMemberLogsFinder {
} }
} }
const includeOwnerSessions =
options?.status === 'in_progress' &&
typeof options?.owner === 'string' &&
options.owner.trim().length > 0;
if (includeOwnerSessions) {
const ownerLogs = await this.findMemberLogs(teamName, options.owner!.trim());
const seen = new Set<string>();
for (const log of results) {
const key =
log.kind === 'subagent'
? `subagent:${log.sessionId}:${log.subagentId}`
: `lead:${log.sessionId}`;
seen.add(key);
}
for (const log of ownerLogs) {
const key =
log.kind === 'subagent'
? `subagent:${log.sessionId}:${log.subagentId}`
: `lead:${log.sessionId}`;
if (!seen.has(key)) {
seen.add(key);
results.push(log);
}
}
}
return results.sort( return results.sort(
(a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime() (a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime()
); );
@ -307,11 +339,18 @@ export class TeamMemberLogsFinder {
private async fileMentionsTaskId(filePath: string, taskId: string): Promise<boolean> { private async fileMentionsTaskId(filePath: string, taskId: string): Promise<boolean> {
const escaped = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escaped = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const patterns = [ const numericTaskId = /^\d+$/.test(taskId) ? taskId : null;
const patterns: RegExp[] = [
new RegExp(`"task_id"\\s*:\\s*"${escaped}"`, 'i'), new RegExp(`"task_id"\\s*:\\s*"${escaped}"`, 'i'),
new RegExp(`"taskId"\\s*:\\s*"${escaped}"`, 'i'), new RegExp(`"taskId"\\s*:\\s*"${escaped}"`, 'i'),
new RegExp(`#${escaped}\\b`), new RegExp(`#${escaped}\\b`),
]; ];
if (numericTaskId) {
patterns.push(
new RegExp(`"task_id"\\s*:\\s*${numericTaskId}\\b`),
new RegExp(`"taskId"\\s*:\\s*${numericTaskId}\\b`)
);
}
try { try {
const stream = createReadStream(filePath, { encoding: 'utf8' }); const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });

View file

@ -573,8 +573,17 @@ const electronAPI: ElectronAPI = {
getMemberLogs: async (teamName: string, memberName: string) => { getMemberLogs: async (teamName: string, memberName: string) => {
return invokeIpcWithResult<MemberLogSummary[]>(TEAM_GET_MEMBER_LOGS, teamName, memberName); return invokeIpcWithResult<MemberLogSummary[]>(TEAM_GET_MEMBER_LOGS, teamName, memberName);
}, },
getLogsForTask: async (teamName: string, taskId: string) => { getLogsForTask: async (
return invokeIpcWithResult<MemberLogSummary[]>(TEAM_GET_LOGS_FOR_TASK, teamName, taskId); teamName: string,
taskId: string,
options?: { owner?: string; status?: string }
) => {
return invokeIpcWithResult<MemberLogSummary[]>(
TEAM_GET_LOGS_FOR_TASK,
teamName,
taskId,
options
);
}, },
getMemberStats: async (teamName: string, memberName: string) => { getMemberStats: async (teamName: string, memberName: string) => {
return invokeIpcWithResult<MemberFullStats>(TEAM_GET_MEMBER_STATS, teamName, memberName); return invokeIpcWithResult<MemberFullStats>(TEAM_GET_MEMBER_STATS, teamName, memberName);

View file

@ -0,0 +1,88 @@
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { cn } from '@renderer/lib/utils';
import { Loader2 } from 'lucide-react';
import { STEP_LABELS, STEP_ORDER } from './provisioningSteps';
import type { ProvisioningStep } from './provisioningSteps';
export interface ProvisioningProgressBlockProps {
/** Title above the steps, e.g. "Launching team" */
title: string;
/** Optional status message */
message?: string | null;
/** Index of the current step in STEP_ORDER (0-based), or -1 if unknown */
currentStepIndex: number;
/** Show spinner next to title */
loading?: boolean;
/** Cancel button label and handler */
onCancel?: (() => void) | null;
className?: string;
}
export const ProvisioningProgressBlock = ({
title,
message,
currentStepIndex,
loading = false,
onCancel,
className,
}: ProvisioningProgressBlockProps): React.JSX.Element => {
return (
<div
className={cn(
'rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2',
className
)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
{loading ? (
<Loader2 className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]" />
) : null}
<p className="text-xs font-medium text-[var(--color-text)]">{title}</p>
</div>
{onCancel ? (
<Button
variant="outline"
size="sm"
className="h-6 shrink-0 px-2 text-xs"
onClick={onCancel}
>
Cancel
</Button>
) : null}
</div>
{message ? <p className="mt-1.5 text-xs text-[var(--color-text-muted)]">{message}</p> : null}
<div className="mt-2 flex items-center gap-1 overflow-x-auto pb-0.5">
{STEP_ORDER.filter((s): s is ProvisioningStep => s !== 'ready').map((step, index) => {
const isDone = currentStepIndex >= 0 && index < currentStepIndex;
const isCurrent = currentStepIndex >= 0 && index === currentStepIndex;
return (
<div key={step} className="flex items-center gap-1">
<Badge
variant="secondary"
className={cn(
'whitespace-nowrap px-2 py-0.5 text-[11px] font-normal',
isDone && 'border-emerald-400/60 bg-emerald-500/10 text-emerald-200',
isCurrent &&
'border-[var(--color-accent)]/70 bg-[var(--color-accent)]/15 text-[var(--color-text)]'
)}
>
<span className="mr-1 inline-flex size-4 items-center justify-center rounded-full border border-current text-[10px]">
{index + 1}
</span>
{STEP_LABELS[step]}
</Badge>
{index < STEP_ORDER.filter((s) => s !== 'ready').length - 1 ? (
<span className="text-[var(--color-text-muted)]">&rarr;</span>
) : null}
</div>
);
})}
</div>
</div>
);
};

View file

@ -9,6 +9,7 @@ import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
import { MessageSquare, Pencil, Play, Plus, Search, Trash2, X } from 'lucide-react'; import { MessageSquare, Pencil, Play, Plus, Search, Trash2, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { ActiveTasksBlock } from './activity/ActiveTasksBlock';
import { ActivityTimeline } from './activity/ActivityTimeline'; import { ActivityTimeline } from './activity/ActivityTimeline';
import { CreateTaskDialog } from './dialogs/CreateTaskDialog'; import { CreateTaskDialog } from './dialogs/CreateTaskDialog';
import { EditTeamDialog } from './dialogs/EditTeamDialog'; import { EditTeamDialog } from './dialogs/EditTeamDialog';
@ -661,6 +662,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
</div> </div>
} }
> >
<ActiveTasksBlock
members={data.members}
tasks={data.tasks}
onMemberClick={setSelectedMember}
onTaskClick={setSelectedTask}
/>
<ActivityTimeline <ActivityTimeline
messages={filteredMessages} messages={filteredMessages}
members={data.members} members={data.members}
@ -678,6 +685,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
<ReviewDialog <ReviewDialog
open={requestChangesTaskId !== null} open={requestChangesTaskId !== null}
teamName={teamName}
taskId={requestChangesTaskId} taskId={requestChangesTaskId}
onCancel={() => setRequestChangesTaskId(null)} onCancel={() => setRequestChangesTaskId(null)}
onSubmit={(comment) => { onSubmit={(comment) => {

View file

@ -1,13 +1,12 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { CheckCircle2, X } from 'lucide-react'; import { CheckCircle2, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { STEP_LABELS, STEP_ORDER } from './provisioningSteps'; import { ProvisioningProgressBlock } from './ProvisioningProgressBlock';
import { STEP_ORDER } from './provisioningSteps';
import type { ProvisioningStep } from './provisioningSteps'; import type { ProvisioningStep } from './provisioningSteps';
import type { TeamProvisioningProgress } from '@shared/types'; import type { TeamProvisioningProgress } from '@shared/types';
@ -137,51 +136,20 @@ export const TeamProvisioningBanner = ({
if (isActive) { if (isActive) {
return ( return (
<div className="mb-3 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2"> <div className="mb-3">
<div className="flex items-center justify-between gap-2"> <ProvisioningProgressBlock
<p className="text-xs text-[var(--color-text-muted)]">{progress.message}</p> title="Launching team"
{canCancel ? ( message={progress.message}
<Button currentStepIndex={progressStepIndex >= 0 ? progressStepIndex : -1}
variant="outline" loading
size="sm" onCancel={
className="h-6 shrink-0 px-2 text-xs" canCancel
onClick={() => { ? () => {
void cancelProvisioning(progress.runId); void cancelProvisioning(progress.runId);
}} }
> : null
Cancel }
</Button> />
) : null}
</div>
<div className="mt-2 flex items-center gap-1 overflow-x-auto pb-0.5">
{STEP_ORDER.map((step, index) => {
const isDone = progressStepIndex >= 0 && index < progressStepIndex;
const isCurrent = progressStepIndex >= 0 && index === progressStepIndex;
return (
<div key={step} className="flex items-center gap-1">
<Badge
variant="secondary"
className={cn(
'whitespace-nowrap px-2 py-0.5 text-[11px] font-normal',
isDone && 'border-emerald-400/60 bg-emerald-500/10 text-emerald-200',
isCurrent &&
'border-[var(--color-accent)]/70 bg-[var(--color-accent)]/15 text-[var(--color-text)]'
)}
>
<span className="mr-1 inline-flex size-4 items-center justify-center rounded-full border border-current text-[10px]">
{index + 1}
</span>
{STEP_LABELS[step]}
</Badge>
{index < STEP_ORDER.length - 1 ? (
<span className="text-[var(--color-text-muted)]">&rarr;</span>
) : null}
</div>
);
})}
</div>
</div> </div>
); );
} }

View file

@ -0,0 +1,121 @@
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { Loader2 } from 'lucide-react';
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
interface ActiveTasksBlockProps {
members: ResolvedTeamMember[];
tasks: TeamTask[];
onMemberClick?: (member: ResolvedTeamMember) => void;
onTaskClick?: (task: TeamTask) => void;
}
export const ActiveTasksBlock = ({
members,
tasks,
onMemberClick,
onTaskClick,
}: ActiveTasksBlockProps): React.JSX.Element | null => {
const taskMap = new Map(tasks.map((t) => [t.id, t]));
const working = members.filter((m) => m.currentTaskId != null);
if (working.length === 0) return null;
return (
<div className="mb-3 space-y-1.5">
<p className="text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Сейчас в работе
</p>
{working.map((member) => {
const taskId = member.currentTaskId!;
const task = taskMap.get(taskId);
const colors = getTeamColorSet(member.color ?? '');
const roleLabel = formatAgentRole(
member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined)
);
return (
<article
key={`${member.name}-${taskId}`}
className="overflow-hidden rounded-md"
style={{
backgroundColor: CARD_BG,
border: CARD_BORDER_STYLE,
borderLeft: `3px solid ${colors.border}`,
}}
>
<div className="flex items-center gap-2 px-3 py-2">
<span className="relative flex size-2 shrink-0">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-70" />
<span className="relative inline-flex size-2 rounded-full bg-emerald-500" />
</span>
<Loader2
className="size-3.5 shrink-0 animate-spin"
style={{ color: colors.border }}
/>
{onMemberClick ? (
<button
type="button"
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
style={{
backgroundColor: colors.badge,
color: colors.text,
border: `1px solid ${colors.border}40`,
}}
onClick={() => onMemberClick(member)}
>
{member.name}
</button>
) : (
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={{
backgroundColor: colors.badge,
color: colors.text,
border: `1px solid ${colors.border}40`,
}}
>
{member.name}
</span>
)}
{roleLabel ? (
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{roleLabel}
</span>
) : null}
<span
className="min-w-0 flex-1 truncate text-[10px]"
style={{ color: CARD_ICON_MUTED }}
>
выполняет
</span>
{task &&
(onTaskClick ? (
<button
type="button"
className="truncate rounded px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
style={{ border: `1px solid ${colors.border}40` }}
onClick={() => onTaskClick(task)}
title={task.subject}
>
#{task.id} {task.subject.slice(0, 40)}
{task.subject.length > 40 ? '…' : ''}
</button>
) : (
<span
className="truncate px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text)]"
style={{ border: `1px solid ${colors.border}40` }}
title={task.subject}
>
#{task.id} {task.subject.slice(0, 40)}
{task.subject.length > 40 ? '…' : ''}
</span>
))}
</div>
</article>
);
})}
</div>
);
};

View file

@ -4,9 +4,14 @@ import type { ParsedMessageReply } from '@renderer/utils/agentMessageFormatting'
interface ReplyQuoteBlockProps { interface ReplyQuoteBlockProps {
reply: ParsedMessageReply; reply: ParsedMessageReply;
/** When set, limits height of the reply body (e.g. "max-h-56"). Omit to show full content. */
bodyMaxHeight?: string;
} }
export const ReplyQuoteBlock = ({ reply }: ReplyQuoteBlockProps): React.JSX.Element => ( export const ReplyQuoteBlock = ({
reply,
bodyMaxHeight = 'max-h-56',
}: ReplyQuoteBlockProps): React.JSX.Element => (
<div className="space-y-2"> <div className="space-y-2">
<div <div
className="rounded-md border-l-2 border-[var(--color-border-emphasis)] bg-[var(--color-surface)] px-3 py-2" className="rounded-md border-l-2 border-[var(--color-border-emphasis)] bg-[var(--color-surface)] px-3 py-2"
@ -17,6 +22,6 @@ export const ReplyQuoteBlock = ({ reply }: ReplyQuoteBlockProps): React.JSX.Elem
</span> </span>
<p className="line-clamp-3 text-xs text-[var(--color-text-muted)]">{reply.originalText}</p> <p className="line-clamp-3 text-xs text-[var(--color-text-muted)]">{reply.originalText}</p>
</div> </div>
<MarkdownViewer content={reply.replyText} maxHeight="max-h-56" copyable /> <MarkdownViewer content={reply.replyText} maxHeight={bodyMaxHeight} copyable />
</div> </div>
); );

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
@ -13,10 +13,12 @@ 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 { Textarea } from '@renderer/components/ui/textarea'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { Check, CheckCircle2, Loader2 } from 'lucide-react'; import { Check, CheckCircle2, Loader2 } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { Project, TeamLaunchRequest, TeamProvisioningPrepareResult } from '@shared/types'; import type { Project, TeamLaunchRequest, TeamProvisioningPrepareResult } from '@shared/types';
interface LaunchTeamDialogProps { interface LaunchTeamDialogProps {
@ -72,7 +74,7 @@ export const LaunchTeamDialog = ({
const [cwdMode, setCwdMode] = useState<'project' | 'custom'>('project'); const [cwdMode, setCwdMode] = useState<'project' | 'custom'>('project');
const [selectedProjectPath, setSelectedProjectPath] = useState(''); const [selectedProjectPath, setSelectedProjectPath] = useState('');
const [customCwd, setCustomCwd] = useState(''); const [customCwd, setCustomCwd] = useState('');
const [prompt, setPrompt] = useState(''); const promptDraft = useDraftPersistence({ key: `launchTeam:${teamName}:prompt` });
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [projectsLoading, setProjectsLoading] = useState(false); const [projectsLoading, setProjectsLoading] = useState(false);
const [projectsError, setProjectsError] = useState<string | null>(null); const [projectsError, setProjectsError] = useState<string | null>(null);
@ -88,7 +90,6 @@ export const LaunchTeamDialog = ({
setPrepareState('idle'); setPrepareState('idle');
setPrepareMessage(null); setPrepareMessage(null);
setPrepareWarnings([]); setPrepareWarnings([]);
setPrompt('');
setCwdMode('project'); setCwdMode('project');
setSelectedProjectPath(''); setSelectedProjectPath('');
setCustomCwd(''); setCustomCwd('');
@ -195,6 +196,16 @@ export const LaunchTeamDialog = ({
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim(); const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>
projects.map((p) => ({
id: p.path,
name: p.name,
subtitle: p.path,
})),
[projects]
);
const activeError = localError ?? provisioningError; const activeError = localError ?? provisioningError;
const handleSubmit = (): void => { const handleSubmit = (): void => {
@ -210,7 +221,7 @@ export const LaunchTeamDialog = ({
await onLaunch({ await onLaunch({
teamName, teamName,
cwd: effectiveCwd, cwd: effectiveCwd,
prompt: prompt.trim() || undefined, prompt: promptDraft.value.trim() || undefined,
}); });
resetFormState(); resetFormState();
onClose(); onClose();
@ -362,12 +373,20 @@ export const LaunchTeamDialog = ({
<Label htmlFor="launch-prompt" className="text-xs text-[var(--color-text-muted)]"> <Label htmlFor="launch-prompt" className="text-xs text-[var(--color-text-muted)]">
Prompt (optional) Prompt (optional)
</Label> </Label>
<Textarea <MentionableTextarea
id="launch-prompt" id="launch-prompt"
className="min-h-[100px] resize-y text-xs" className="min-h-[100px] text-xs"
value={prompt} minRows={4}
onChange={(event) => setPrompt(event.target.value)} maxRows={12}
placeholder="Instructions for team lead..." value={promptDraft.value}
onValueChange={promptDraft.setValue}
suggestions={mentionSuggestions}
placeholder="Instructions for team lead... Use @ to mention projects."
footerRight={
promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null
}
/> />
</div> </div>
</div> </div>

View file

@ -1,5 +1,3 @@
import { useState } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { import {
Dialog, Dialog,
@ -11,9 +9,11 @@ import {
} from '@renderer/components/ui/dialog'; } from '@renderer/components/ui/dialog';
import { Label } from '@renderer/components/ui/label'; import { Label } from '@renderer/components/ui/label';
import { Textarea } from '@renderer/components/ui/textarea'; import { Textarea } from '@renderer/components/ui/textarea';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
interface ReviewDialogProps { interface ReviewDialogProps {
open: boolean; open: boolean;
teamName: string;
taskId: string | null; taskId: string | null;
onCancel: () => void; onCancel: () => void;
onSubmit: (comment?: string) => void; onSubmit: (comment?: string) => void;
@ -21,20 +21,23 @@ interface ReviewDialogProps {
export const ReviewDialog = ({ export const ReviewDialog = ({
open, open,
teamName,
taskId, taskId,
onCancel, onCancel,
onSubmit, onSubmit,
}: ReviewDialogProps): React.JSX.Element => { }: ReviewDialogProps): React.JSX.Element => {
const [comment, setComment] = useState(''); const draft = useDraftPersistence({
key: `requestChanges:${teamName}:${taskId ?? ''}`,
enabled: Boolean(teamName && taskId),
});
const handleCancel = (): void => { const handleCancel = (): void => {
setComment('');
onCancel(); onCancel();
}; };
const handleSubmit = (): void => { const handleSubmit = (): void => {
const trimmed = comment.trim() || undefined; const trimmed = draft.value.trim() || undefined;
setComment(''); draft.clearDraft();
onSubmit(trimmed); onSubmit(trimmed);
}; };
@ -58,10 +61,13 @@ export const ReviewDialog = ({
<Textarea <Textarea
id="review-comment" id="review-comment"
className="min-h-[110px] text-xs" className="min-h-[110px] text-xs"
value={comment} value={draft.value}
placeholder="Describe what needs to change..." placeholder="Describe what needs to change..."
onChange={(event) => setComment(event.target.value)} onChange={(event) => draft.setValue(event.target.value)}
/> />
{draft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null}
</div> </div>
<DialogFooter> <DialogFooter>

View file

@ -11,7 +11,7 @@ import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessage
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 { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { MessageSquare, Reply, Send, X } from 'lucide-react'; import { ChevronDown, ChevronUp, MessageSquare, Reply, Send, X } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember, TaskComment } from '@shared/types'; import type { ResolvedTeamMember, TaskComment } from '@shared/types';
@ -36,6 +36,16 @@ export const TaskCommentsSection = ({
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 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}` });
@ -106,6 +116,18 @@ export const TaskCommentsSection = ({
: formatDistanceToNow(date, { addSuffix: true }); : formatDistanceToNow(date, { addSuffix: true });
})()} })()}
</span> </span>
<button
type="button"
className="flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
onClick={() => toggleCommentExpanded(comment.id)}
title={expandedCommentIds.has(comment.id) ? 'Свернуть' : 'Развернуть'}
>
{expandedCommentIds.has(comment.id) ? (
<ChevronUp size={12} />
) : (
<ChevronDown size={12} />
)}
</button>
<button <button
type="button" type="button"
className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100" className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
@ -123,10 +145,17 @@ export const TaskCommentsSection = ({
<div className="text-xs"> <div className="text-xs">
{(() => { {(() => {
const reply = parseMessageReply(comment.text); const reply = parseMessageReply(comment.text);
const expanded = expandedCommentIds.has(comment.id);
return reply ? ( return reply ? (
<ReplyQuoteBlock reply={reply} /> <ReplyQuoteBlock
reply={reply}
bodyMaxHeight={expanded ? undefined : 'max-h-56'}
/>
) : ( ) : (
<MarkdownViewer content={comment.text} maxHeight="max-h-[120px]" /> <MarkdownViewer
content={comment.text}
maxHeight={expanded ? undefined : 'max-h-[120px]'}
/>
); );
})()} })()}
</div> </div>

View file

@ -1,3 +1,5 @@
import { useEffect } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
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';
@ -10,6 +12,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@renderer/components/ui/dialog'; } from '@renderer/components/ui/dialog';
import { markAsRead } from '@renderer/services/commentReadStorage';
import { TASK_STATUS_LABELS, TASK_STATUS_STYLES } from '@renderer/utils/memberHelpers'; import { TASK_STATUS_LABELS, TASK_STATUS_STYLES } from '@renderer/utils/memberHelpers';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { import {
@ -48,6 +51,19 @@ export const TaskDetailDialog = ({
}: TaskDetailDialogProps): React.JSX.Element => { }: TaskDetailDialogProps): React.JSX.Element => {
const currentTask = task ? (taskMap.get(task.id) ?? task) : null; const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
useEffect(() => {
if (!open || !currentTask) return;
const comments = currentTask.comments ?? [];
if (comments.length === 0) return;
const latest = Math.max(...comments.map((c) => new Date(c.createdAt).getTime()));
if (latest > 0) markAsRead(teamName, currentTask.id, latest);
}, [open, teamName, currentTask?.id, currentTask?.comments]);
const handleDependencyClick = (taskId: string): void => {
onClose();
onScrollToTask?.(taskId);
};
if (!currentTask) { if (!currentTask) {
return ( return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}> <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
@ -66,11 +82,6 @@ export const TaskDetailDialog = ({
const blockedByIds = currentTask.blockedBy?.filter((id) => id.length > 0) ?? []; const blockedByIds = currentTask.blockedBy?.filter((id) => id.length > 0) ?? [];
const blocksIds = currentTask.blocks?.filter((id) => id.length > 0) ?? []; const blocksIds = currentTask.blocks?.filter((id) => id.length > 0) ?? [];
const handleDependencyClick = (taskId: string): void => {
onClose();
onScrollToTask?.(taskId);
};
return ( return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}> <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-h-[85vh] min-w-0 overflow-y-auto overflow-x-hidden sm:max-w-4xl"> <DialogContent className="max-h-[85vh] min-w-0 overflow-y-auto overflow-x-hidden sm:max-w-4xl">
@ -222,7 +233,12 @@ export const TaskDetailDialog = ({
<h4 className="mb-2 text-xs font-medium text-[var(--color-text-muted)]"> <h4 className="mb-2 text-xs font-medium text-[var(--color-text-muted)]">
Execution Logs Execution Logs
</h4> </h4>
<MemberLogsTab teamName={teamName} taskId={currentTask.id} /> <MemberLogsTab
teamName={teamName}
taskId={currentTask.id}
taskOwner={currentTask.owner}
taskStatus={currentTask.status}
/>
</div> </div>
<DialogFooter> <DialogFooter>

View file

@ -20,12 +20,17 @@ interface MemberLogsTabProps {
teamName: string; teamName: string;
memberName?: string; memberName?: string;
taskId?: string; taskId?: string;
/** When viewing task logs: include owner's sessions when task is in_progress */
taskOwner?: string;
taskStatus?: string;
} }
export const MemberLogsTab = ({ export const MemberLogsTab = ({
teamName, teamName,
memberName, memberName,
taskId, taskId,
taskOwner,
taskStatus,
}: MemberLogsTabProps): React.JSX.Element => { }: MemberLogsTabProps): React.JSX.Element => {
const [logs, setLogs] = useState<MemberLogSummary[]>([]); const [logs, setLogs] = useState<MemberLogSummary[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -47,7 +52,10 @@ export const MemberLogsTab = ({
} }
const result = const result =
taskId != null taskId != null
? await api.teams.getLogsForTask(teamName, taskId) ? await api.teams.getLogsForTask(teamName, taskId, {
owner: taskOwner,
status: taskStatus,
})
: await api.teams.getMemberLogs(teamName, memberName!); : await api.teams.getMemberLogs(teamName, memberName!);
if (!cancelled) { if (!cancelled) {
setLogs(result); setLogs(result);
@ -66,7 +74,7 @@ export const MemberLogsTab = ({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [teamName, memberName, taskId]); }, [teamName, memberName, taskId, taskOwner, taskStatus]);
const handleExpand = useCallback( const handleExpand = useCallback(
async (log: MemberLogSummary) => { async (log: MemberLogSummary) => {

View file

@ -23,10 +23,10 @@ const badgeVariants = cva(
); );
export interface BadgeProps export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {} extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
const Badge = ({ className, variant, ...props }: BadgeProps): React.JSX.Element => { const Badge = ({ className, variant, ...props }: BadgeProps): React.JSX.Element => {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />; return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
}; };
// eslint-disable-next-line react-refresh/only-export-components -- Standard shadcn export pattern // eslint-disable-next-line react-refresh/only-export-components -- Standard shadcn export pattern

View file

@ -39,11 +39,13 @@ const DialogContent = React.forwardRef<
)} )}
{...props} {...props}
> >
{children} <div className="sticky top-0 z-10 -mr-6 -mt-6 h-0 w-full overflow-visible">
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-1 focus:ring-[var(--color-border-emphasis)] disabled:pointer-events-none"> <DialogPrimitive.Close className="absolute right-0 top-0 rounded-sm p-1.5 opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-1 focus:ring-[var(--color-border-emphasis)] disabled:pointer-events-none">
<X className="size-4 text-[var(--color-text-muted)]" /> <X className="size-4 text-[var(--color-text-muted)]" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</div>
{children}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
)); ));

View file

@ -361,7 +361,11 @@ export interface TeamsAPI {
aliveList: () => Promise<string[]>; aliveList: () => Promise<string[]>;
createConfig: (request: TeamCreateConfigRequest) => Promise<void>; createConfig: (request: TeamCreateConfigRequest) => Promise<void>;
getMemberLogs: (teamName: string, memberName: string) => Promise<MemberLogSummary[]>; getMemberLogs: (teamName: string, memberName: string) => Promise<MemberLogSummary[]>;
getLogsForTask: (teamName: string, taskId: string) => Promise<MemberLogSummary[]>; getLogsForTask: (
teamName: string,
taskId: string,
options?: { owner?: string; status?: string }
) => Promise<MemberLogSummary[]>;
getMemberStats: (teamName: string, memberName: string) => Promise<MemberFullStats>; getMemberStats: (teamName: string, memberName: string) => Promise<MemberFullStats>;
launchTeam: (request: TeamLaunchRequest) => Promise<TeamLaunchResponse>; launchTeam: (request: TeamLaunchRequest) => Promise<TeamLaunchResponse>;
getAllTasks: () => Promise<GlobalTask[]>; getAllTasks: () => Promise<GlobalTask[]>;