refactor: enhance task management protocols and UI components

- Updated task management guidelines to emphasize the importance of posting task comments before marking tasks as complete, ensuring visibility of results on the task board.
- Introduced a new notification system for team leads after task completion, summarizing key findings and linking to detailed comments.
- Improved UI components, including a new CLI checking spinner with a delayed hint and enhanced task filters to support unread/read task states.
- Refactored sidebar task item styles to visually indicate unread tasks and adjusted task sorting options to include 'unread' as a criterion.
- Enhanced settings tabs with tooltips for better user guidance and improved layout for advanced settings options.
This commit is contained in:
iliya 2026-03-20 13:45:03 +02:00
parent 1d15f5f4d9
commit ec547e0662
18 changed files with 421 additions and 218 deletions

View file

@ -392,10 +392,12 @@ function buildMemberTaskProtocol(teamName) {
- Do NOT start multiple tasks at once unless the team lead explicitly directs parallel work.
3. Use MCP tool task_complete BEFORE sending your final reply:
{ teamName: "${teamName}", taskId: "<taskId>" }
- CRITICAL: Before calling task_complete, you MUST post a task comment with your results (findings, research report, analysis, code changes summary, or any deliverable). The task comment is the primary delivery channel the user reads results on the task board. A SendMessage to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only SendMessage without a task comment, the user will never see your work.
- If a new task comment means you must do more real work on that same task, FIRST add a short task comment saying what you are going to do, THEN run task_start again before doing the follow-up work.
- After that follow-up work finishes, add a short task comment with the result, what changed, or what you verified.
- After that, run task_complete again before your reply.
- Never do comment-driven implementation/fix work while the task is still shown as pending, review, completed, or approved.
- After task_complete, send a notification to your team lead via SendMessage that includes: (a) which task is done (#<displayId>), (b) a brief summary of the outcome/key findings (2-4 sentences enough to understand the gist without reading the full comment), (c) mention that the full detailed results are in the task comment (include the commentId returned by task_add_comment so the lead can reference it, e.g. "Full report in task comment <commentId>"), (d) what you will do next. Do NOT duplicate the entire results keep it concise.
- After task_complete, if the task needs review AND the team has a member whose role includes reviewing (e.g. "reviewer", "tech-lead", "qa"), IMMEDIATELY call review_request to move it to the review column and notify the reviewer:
{ teamName: "${teamName}", taskId: "<taskId>", from: "<your-name>", reviewer: "<reviewer-name>" }
Do NOT leave a completed task without sending it to review when review is expected and a reviewer exists.
@ -546,6 +548,8 @@ async function memberBriefing(context, memberName) {
`Member briefing for ${requestedMemberName} on team "${context.teamName}" (${context.teamName}).`,
`Role: ${role}.`,
`CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST leave a short task comment saying what you are about to do, THEN move it to in_progress with task_start, THEN do the work, and when finished leave a short result comment and move it to done with task_complete. Never skip this comment -> reopen -> work -> comment -> done cycle.`,
`CRITICAL: When you finish a task, your results (findings, research report, analysis, code changes summary, or any deliverable) MUST be posted as a task comment BEFORE calling task_complete. The task comment is the primary delivery channel — the user reads results on the task board. A SendMessage to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only SendMessage without a task comment, the user will never see your work.`,
`After task_complete, send a notification to your team lead via SendMessage: include which task is done (#<displayId>), a brief summary of the outcome/key findings (2-4 sentences), mention that full details are in the task comment (include the commentId from the task_add_comment response), and what you will do next. Do NOT duplicate the entire results — keep it concise.`,
`CRITICAL: A newly assigned task must NOT remain silently pending/TODO. If you are idle and the task is ready to start, start it now. If it must wait because you are already finishing another task, blocked, or still need more context, leave a short task comment on the waiting task immediately with the reason and your best ETA or what you are waiting on, keep it in pending/TODO, and only move it to in_progress with task_start when you truly begin.`,
`Team lead: ${leadName}.`,
buildMemberLanguageInstruction(config),

View file

@ -112,7 +112,7 @@ const PREFLIGHT_AUTH_MAX_RETRIES = 2;
const FS_MONITOR_POLL_MS = 2000;
const TASK_WAIT_FALLBACK_MS = 15_000;
const STALL_CHECK_INTERVAL_MS = 10_000;
const STALL_WARNING_THRESHOLD_MS = 45_000;
const STALL_WARNING_THRESHOLD_MS = 20_000;
const STALL_WARNING_REPEAT_MS = 30_000;
const TEAM_JSON_READ_TIMEOUT_MS = 5_000;
const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024;
@ -453,7 +453,9 @@ After member_briefing succeeds:
- If a newly assigned task cannot be started immediately because you are still busy on another task, leave a short task comment on that waiting task right away with the reason and your best ETA, keep it in pending/TODO, and only move it to in_progress with task_start when you truly begin.
- CRITICAL: If someone comments on your task, you MUST reply on that same task via task_add_comment. Never leave a user/lead/teammate task comment unanswered, even if the reply is only a short acknowledgement or status update. Do NOT treat status changes or direct messages as a substitute for an on-task reply.
- CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST leave a short task comment saying what you are about to do, THEN move it to in_progress with task_start, THEN do the work, and when finished leave a short result comment and move it to done with task_complete. Never skip this comment -> reopen -> work -> comment -> done cycle.
- Direct messages to your team lead are only for urgent attention, no-task situations, or when the lead explicitly asked for a direct reply.
- CRITICAL: When you finish a task, your results (findings, research report, analysis, code changes summary, or any deliverable) MUST be posted as a task comment BEFORE calling task_complete. The task comment is the primary delivery channel the user reads results on the task board. A SendMessage to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only SendMessage without a task comment, the user will never see your work.
- After task_complete, send a notification to your team lead via SendMessage that includes: (a) which task is done (#<displayId>), (b) a brief summary of the outcome/key findings (2-4 sentences enough to understand the gist without reading the full comment), (c) mention that full details are in the task comment (include the commentId returned by task_add_comment, e.g. "Full report in task comment <commentId>"), (d) what you will do next. Do NOT duplicate the entire results keep it concise.
- Beyond task-completion pings, direct messages to your team lead are only for urgent attention, no-task situations, or when the lead explicitly asked for a direct reply.
- If a task-scoped update is already recorded in a task comment, do NOT send a duplicate SendMessage to the lead with the same content unless you need urgent non-task attention. When skipping a message, stay silent never output meta-commentary about skipped or already-delivered messages.
${buildTeammateAgentBlockReminder()}
${actionModeProtocol}`;
@ -501,7 +503,9 @@ ${actionModeProtocol}
- If you are the one about to do the implementation/fixes and the owner is missing or someone else, run task_set_owner to yourself immediately before task_start.
- Only then run task_start when you truly begin.
- If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on it, FIRST leave a short task comment saying what you are about to do, THEN run task_start, then do the work, and when finished leave a short result comment and run task_complete again. Never skip this comment -> reopen -> work -> comment -> done cycle.
- Direct messages to your team lead are only for urgent attention, no-task situations, or when the lead explicitly asked for a direct reply.
- CRITICAL: When you finish a task, your results (findings, research report, analysis, code changes summary, or any deliverable) MUST be posted as a task comment BEFORE calling task_complete. The task comment is the primary delivery channel the user reads results on the task board. A SendMessage to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only SendMessage without a task comment, the user will never see your work.
- After task_complete, send a notification to your team lead via SendMessage that includes: (a) which task is done (#<displayId>), (b) a brief summary of the outcome/key findings (2-4 sentences enough to understand the gist without reading the full comment), (c) mention that full details are in the task comment (include the commentId returned by task_add_comment, e.g. "Full report in task comment <commentId>"), (d) what you will do next. Do NOT duplicate the entire results keep it concise.
- Beyond task-completion pings, direct messages to your team lead are only for urgent attention, no-task situations, or when the lead explicitly asked for a direct reply.
- If a task-scoped update is already recorded in a task comment, do NOT send a duplicate SendMessage to the lead with the same content unless you need urgent non-task attention. When skipping a message, stay silent never output meta-commentary about skipped or already-delivered messages.
- If you have no tasks, wait for new assignments.`;
}
@ -551,6 +555,7 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
`- If you assign work to a teammate who already has another in_progress task, create/keep the newly assigned task in pending/TODO. Do NOT move it to in_progress on their behalf before they actually start.`,
`- Never bulk-move many tasks at the end of a session — update status incrementally as you work.`,
`- Record meaningful progress, decisions, and blockers as task comments so context is preserved on the board.`,
`- CRITICAL: Task results (findings, reports, analysis, code changes) MUST be posted as task comments — the user reads results on the task board. Direct messages alone are not visible on the board and the user will miss them.`,
``,
`Parallelization guideline (IMPORTANT):`,
`- If a task is genuinely parallelizable, split it into multiple smaller tasks owned by different members.`,
@ -958,7 +963,7 @@ function buildLaunchPrompt(
- BEFORE doing any work on a task: mark it started (in_progress).
- Immediately SendMessage "user" that you started task #<id> (what you're doing + next step).
- While working: after each meaningful milestone/decision/blocker, add a task comment on #<id>. If the milestone is user-relevant, also SendMessage "user".
- On completion: add a final task comment (what changed + how to verify), mark the task completed, then SendMessage "user" that task #<id> is complete and what you will do next.
- On completion: add a final task comment with your full results (findings, report, analysis, code changes summary, or any deliverable), mark the task completed, then SendMessage "user" with a brief summary of the outcome (2-4 sentences) and mention that full details are in the task comment (include the commentId from the task_add_comment response). The task comment is the primary delivery channel the user reads results on the task board.
- Do NOT start the next task until the current task is completed (default: one task in_progress at a time).
For this reconnect turn: review the task board snapshot above and output a short summary (12 sentences) confirming reconnect is complete and you are ready.`;
@ -2390,8 +2395,8 @@ export class TeamProvisioningService {
const label = status ? `API Error ${status}` : 'API Error';
const warningText = snippet
? `**${label} — SDK is retrying**\n\n\`\`\`\n${snippet}\n\`\`\`\n\nОжидаем повторной попытки...`
: `**${label} — SDK is retrying**\n\nОжидаем повторной попытки...`;
? `**${label} — SDK is retrying**\n\n\`\`\`\n${snippet}\n\`\`\`\n\nWaiting for retry...`
: `**${label} — SDK is retrying**\n\nWaiting for retry...`;
run.provisioningOutputParts.push(warningText);
run.progress.message = `${label} — SDK retrying...`;
@ -2434,11 +2439,11 @@ export class TeamProvisioningService {
lastWarningAt = now;
const silenceSec = Math.round(silenceMs / 1000);
run.provisioningOutputParts.push(this.buildStallWarningText(silenceSec));
run.provisioningOutputParts.push(this.buildStallWarningText(silenceSec, run));
const mins = Math.floor(silenceSec / 60);
const secs = silenceSec % 60;
const elapsed = mins > 0 ? `${mins} мин ${secs > 0 ? `${secs} сек` : ''}` : `${secs} сек`;
run.progress.message = `CLI не отвечает ${elapsed} — возможен rate limit`;
const elapsed = mins > 0 ? `${mins}m ${secs > 0 ? `${secs}s` : ''}` : `${secs}s`;
run.progress.message = `CLI not responding for ${elapsed} — possible rate limit`;
emitLogsProgress(run);
} catch (err) {
logger.error(
@ -2457,40 +2462,45 @@ export class TeamProvisioningService {
}
}
private buildStallWarningText(silenceSec: number): string {
private buildStallWarningText(silenceSec: number, run: ProvisioningRun): string {
const mins = Math.floor(silenceSec / 60);
const secs = silenceSec % 60;
const elapsed = mins > 0 ? `${mins} мин ${secs > 0 ? `${secs} сек` : ''}` : `${secs} сек`;
const elapsed = mins > 0 ? `${mins}m ${secs > 0 ? `${secs}s` : ''}` : `${secs}s`;
if (silenceSec < 60) {
return (
`---\n\n` +
`**Ожидание ответа CLI** (тишина ${elapsed})\n\n` +
`Процесс запущен, но пока не выдаёт данных. ` +
`Это может быть вызвано задержкой API (rate limit / model cooldown) — ` +
`SDK выполняет повторные попытки автоматически.\n\n` +
`Ожидаем...`
`**Waiting for CLI response** (silent for ${elapsed})\n\n` +
`The process is running but not producing output yet. ` +
`This may be caused by an API delay (rate limit / model cooldown) — ` +
`the SDK retries automatically.\n\n` +
`Waiting...`
);
}
if (silenceSec < 120) {
return (
`---\n\n` +
`**Ожидание ответа CLI** (тишина ${elapsed})\n\n` +
`Процесс по-прежнему не отвечает. Вероятна задержка из-за rate limiting ` +
`(ошибка 429 / model cooldown). SDK автоматически повторяет запрос` +
`обычно это проходит в течение 1-3 минут.\n\n` +
`Можно отменить и попробовать позже, если ожидание затянется.`
`**Waiting for CLI response** (silent for ${elapsed})\n\n` +
`The process is still not responding. Likely delayed due to rate limiting ` +
`(error 429 / model cooldown). The SDK retries the request automatically` +
`this usually resolves within 1-3 minutes.\n\n` +
`You can cancel and try again later if the wait continues.`
);
}
const modelName = run.request.model ?? 'default';
const effortLabel = run.request.effort ? ` (effort: ${run.request.effort})` : '';
return (
`---\n\n` +
`**Длительное ожидание CLI** (тишина ${elapsed})\n\n` +
`Процесс молчит уже более ${mins} минут. Вероятные причины:\n` +
`- Rate limiting / model cooldown (429) — SDK повторяет автоматически\n` +
`- Перегрузка API сервера\n\n` +
`Рекомендуем отменить и попробовать через несколько минут.`
`**Extended CLI wait** (silent for ${elapsed})\n\n` +
`Model **${modelName}**${effortLabel} appears to be under heavy load and is not responding. ` +
`Most likely this is a 429 error (rate limit / model cooldown).\n\n` +
`The process has been silent for over ${mins} minutes. Possible causes:\n` +
`- Rate limiting / model cooldown (429) — SDK retries automatically\n` +
`- API server overload for this model\n\n` +
`Consider canceling and trying with a different model.`
);
}
@ -5620,7 +5630,7 @@ export class TeamProvisioningService {
`- BEFORE doing any work on a task: mark it started (in_progress).`,
`- Immediately SendMessage "user" that you started task #<id> (what you're doing + next step).`,
`- While working: after each meaningful milestone/decision/blocker, add a task comment on #<id>. If user-relevant, also SendMessage "user".`,
`- On completion: add a final task comment (what changed + how to verify), mark the task completed, then SendMessage "user" that task #<id> is complete and what you will do next.`,
`- On completion: add a final task comment with your full results (findings, report, analysis, code changes summary, or any deliverable), then mark the task completed, then SendMessage "user" with a brief summary of the outcome (2-4 sentences) and mention that full details are in the task comment (include the commentId from the task_add_comment response). The task comment is the primary delivery channel — the user reads results on the task board.`,
`- Do NOT start the next task until the current task is completed (default: one task in_progress at a time).`,
board.trim(),
]

View file

@ -109,6 +109,47 @@ const ErrorDisplay = ({
);
};
// =============================================================================
// CLI checking spinner with delayed hint
// =============================================================================
const SLOW_CHECK_DELAY_MS = 5_000;
const CliCheckingSpinner = ({
styles,
}: {
styles: { border: string; bg: string };
}): React.JSX.Element => {
const [showHint, setShowHint] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setShowHint(true), SLOW_CHECK_DELAY_MS);
return () => clearTimeout(timer);
}, []);
return (
<div
className={`mb-6 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
>
<Loader2
className="size-4 shrink-0 animate-spin"
style={{ color: 'var(--color-text-muted)' }}
/>
<div>
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
Checking Claude CLI...
</span>
{showHint && (
<p className="mt-0.5 text-xs" style={{ color: 'var(--color-text-muted)', opacity: 0.7 }}>
First check may take up to 30 seconds
</p>
)}
</div>
</div>
);
};
// =============================================================================
// Installed banner (extracted sub-component)
// =============================================================================
@ -338,20 +379,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
}
// Loading state: show spinner only while an actual request is in-flight.
return (
<div
className={`mb-6 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
>
<Loader2
className="size-4 shrink-0 animate-spin"
style={{ color: 'var(--color-text-muted)' }}
/>
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
Checking Claude CLI...
</span>
</div>
);
return <CliCheckingSpinner styles={styles} />;
}
// ── Downloading ────────────────────────────────────────────────────────

View file

@ -7,7 +7,7 @@
* - Border-first project cards with minimal backgrounds
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
@ -44,6 +44,7 @@ interface CommandSearchProps {
const CommandSearch = ({ value, onChange }: Readonly<CommandSearchProps>): React.JSX.Element => {
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { openCommandPalette, selectedProjectId } = useStore(
useShallow((s) => ({
openCommandPalette: s.openCommandPalette,
@ -64,6 +65,21 @@ const CommandSearch = ({ value, onChange }: Readonly<CommandSearchProps>): React
return () => window.removeEventListener('keydown', handleKeyDown);
}, [openCommandPalette]);
// Focus search when the dashboard mounts (packaged Electron can skip native autoFocus).
useLayoutEffect(() => {
const el = inputRef.current;
if (!el) {
return;
}
el.focus({ preventScroll: true });
const t = window.setTimeout(() => {
if (document.activeElement !== el) {
el.focus({ preventScroll: true });
}
}, 50);
return () => window.clearTimeout(t);
}, []);
return (
<div className="relative w-full">
{/* Search container with glow effect on focus */}
@ -76,6 +92,7 @@ const CommandSearch = ({ value, onChange }: Readonly<CommandSearchProps>): React
>
<Search className="size-4 shrink-0 text-text-muted" />
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}

View file

@ -96,7 +96,7 @@ export const Sidebar = (): React.JSX.Element => {
}}
>
<div
className="flex min-w-0 flex-1 flex-col overflow-hidden pl-2"
className="flex min-w-0 flex-1 flex-col overflow-hidden"
style={{
width: '100%',
minWidth: sidebarCollapsed ? 0 : width,

View file

@ -1,7 +1,15 @@
import { useMemo, useState } from 'react';
import { useMemo } from 'react';
import { isElectronMode } from '@renderer/api';
import { Bell, Server, Settings, Wrench } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
import { Bell, Info, Settings, Wrench } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
export type SettingsSection = 'general' | 'connection' | 'notifications' | 'advanced';
@ -13,22 +21,40 @@ interface SettingsTabsProps {
interface TabConfig {
id: SettingsSection;
label: string;
icon: React.ComponentType<{ className?: string }>;
icon: LucideIcon;
description: string;
electronOnly?: boolean;
}
const tabs: TabConfig[] = [
{ id: 'general', label: 'General', icon: Settings },
// { id: 'connection', label: 'Connection', icon: Server, electronOnly: true },
{ id: 'notifications', label: 'Notifications', icon: Bell },
{ id: 'advanced', label: 'Advanced', icon: Wrench },
{
id: 'general',
label: 'General',
icon: Settings,
description:
'Core app preferences like theme, language, display density, and startup behavior.',
},
// { id: 'connection', label: 'Connection', icon: Server, description: 'Manage CLI connection and authentication settings.', electronOnly: true },
{
id: 'notifications',
label: 'Notifications',
icon: Bell,
description:
'Control when and how you get notified about agent activity, task completions, and errors.',
},
{
id: 'advanced',
label: 'Advanced',
icon: Wrench,
description:
'Power-user options: export/import config, reset defaults, and raw configuration editing.',
},
];
export const SettingsTabs = ({
activeSection,
onSectionChange,
}: Readonly<SettingsTabsProps>): React.JSX.Element => {
const [hoveredTab, setHoveredTab] = useState<SettingsSection | null>(null);
const isElectron = useMemo(() => isElectronMode(), []);
const visibleTabs = useMemo(
() => tabs.filter((tab) => !tab.electronOnly || isElectron),
@ -36,37 +62,53 @@ export const SettingsTabs = ({
);
return (
<div className="flex gap-1 border-b" style={{ borderColor: 'var(--color-border)' }}>
{visibleTabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeSection === tab.id;
const isHovered = hoveredTab === tab.id;
<TooltipProvider>
<div className="border-b border-border pb-0">
<div className="inline-flex h-9 items-center gap-1 rounded-t-lg bg-[var(--color-surface-raised)] p-1 text-[var(--color-text-muted)]">
{visibleTabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeSection === tab.id;
const getTextColor = (): string => {
if (isActive) return 'var(--color-text)';
if (isHovered) return 'var(--color-text-secondary)';
return 'var(--color-text-muted)';
};
return (
<button
key={tab.id}
onClick={() => onSectionChange(tab.id)}
className={`relative inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1 pr-7 text-sm font-medium transition-all ${
isActive
? 'bg-[var(--color-surface)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
}`}
>
<Icon className="size-3.5" />
{tab.label}
return (
<button
key={tab.id}
onClick={() => onSectionChange(tab.id)}
onMouseEnter={() => setHoveredTab(tab.id)}
onMouseLeave={() => setHoveredTab(null)}
className={`flex items-center gap-2 px-3 py-2 text-sm transition-colors ${
isActive ? 'rounded-md font-medium' : ''
}`}
style={{
backgroundColor: isActive ? 'var(--color-surface-raised)' : 'transparent',
color: getTextColor(),
}}
>
<Icon className="size-4" />
<span>{tab.label}</span>
</button>
);
})}
</div>
<Tooltip>
<TooltipTrigger asChild>
<span
role="button"
tabIndex={0}
aria-label={`What is ${tab.label}?`}
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.stopPropagation();
}
}}
className="size-4.5 absolute right-1.5 top-0.5 z-10 inline-flex items-center justify-center rounded-full text-text-muted transition-colors hover:bg-[var(--color-surface-raised)] hover:text-text"
>
<Info className="size-3" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-64 text-pretty text-xs leading-relaxed">
{tab.description}
</TooltipContent>
</Tooltip>
</button>
);
})}
</div>
</div>
</TooltipProvider>
);
};

View file

@ -96,10 +96,10 @@ export const AdvancedSection = ({
return (
<div>
<SettingsSectionHeader title="Configuration" />
<div className="space-y-2 py-2">
<div className="flex flex-wrap gap-2 py-2">
<button
onClick={() => setConfigEditorOpen(true)}
className="flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2.5 text-sm font-medium transition-all duration-150 hover:bg-white/5"
className="flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition-all duration-150 hover:bg-white/5"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text)',
@ -111,7 +111,7 @@ export const AdvancedSection = ({
<button
onClick={onResetToDefaults}
disabled={saving}
className={`flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2.5 text-sm font-medium transition-all duration-150 ${saving ? 'cursor-not-allowed opacity-50' : ''} `}
className={`flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition-all duration-150 hover:bg-white/5 ${saving ? 'cursor-not-allowed opacity-50' : ''}`}
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
@ -123,7 +123,7 @@ export const AdvancedSection = ({
<button
onClick={onExportConfig}
disabled={saving}
className={`flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2.5 text-sm font-medium transition-all duration-150 ${saving ? 'cursor-not-allowed opacity-50' : ''} `}
className={`flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition-all duration-150 hover:bg-white/5 ${saving ? 'cursor-not-allowed opacity-50' : ''}`}
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
@ -135,7 +135,7 @@ export const AdvancedSection = ({
<button
onClick={onImportConfig}
disabled={saving}
className={`flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2.5 text-sm font-medium transition-all duration-150 ${saving ? 'cursor-not-allowed opacity-50' : ''} `}
className={`flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition-all duration-150 hover:bg-white/5 ${saving ? 'cursor-not-allowed opacity-50' : ''}`}
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
@ -147,7 +147,7 @@ export const AdvancedSection = ({
{isElectron && (
<button
onClick={onOpenInEditor}
className="flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2.5 text-sm font-medium transition-all duration-150"
className="flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition-all duration-150 hover:bg-white/5"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',

View file

@ -67,12 +67,13 @@ function saveGroupingMode(mode: TaskGroupingMode): void {
}
}
export type TaskSortMode = 'time' | 'project' | 'team';
export type TaskSortMode = 'time' | 'project' | 'team' | 'unread';
const TASK_SORT_STORAGE_KEY = 'sidebarTasksSort';
const SORT_OPTIONS: { id: TaskSortMode; label: string }[] = [
{ id: 'time', label: 'By time' },
{ id: 'unread', label: 'By unread' },
{ id: 'project', label: 'By project' },
{ id: 'team', label: 'By team' },
];
@ -80,7 +81,7 @@ const SORT_OPTIONS: { id: TaskSortMode; label: string }[] = [
function loadSortMode(): TaskSortMode {
try {
const v = localStorage.getItem(TASK_SORT_STORAGE_KEY);
if (v === 'time' || v === 'project' || v === 'team') return v;
if (v === 'time' || v === 'project' || v === 'team' || v === 'unread') return v;
} catch {
/* ignore */
}
@ -95,11 +96,22 @@ function saveSortMode(mode: TaskSortMode): void {
}
}
function applySortMode(tasks: GlobalTask[], mode: TaskSortMode): GlobalTask[] {
function applySortMode(
tasks: GlobalTask[],
mode: TaskSortMode,
readState?: ReturnType<typeof useReadStateSnapshot>
): GlobalTask[] {
const sorted = [...tasks];
switch (mode) {
case 'time':
return sortTasksByFreshness(sorted);
case 'unread':
return sorted.sort((a, b) => {
const ua = readState ? getTaskUnreadCount(readState, a.teamName, a.id, a.comments) : 0;
const ub = readState ? getTaskUnreadCount(readState, b.teamName, b.id, b.comments) : 0;
if (ub !== ua) return ub - ua;
return (b.updatedAt ?? b.createdAt ?? '').localeCompare(a.updatedAt ?? a.createdAt ?? '');
});
case 'project':
return sorted.sort((a, b) => {
const pa = a.projectPath ?? '';
@ -324,10 +336,14 @@ export const GlobalTaskList = ({
if (filters.teamName) {
result = result.filter((t) => t.teamName === filters.teamName);
}
if (filters.unreadOnly) {
if (filters.readFilter === 'unread') {
result = result.filter(
(t) => getTaskUnreadCount(readState, t.teamName, t.id, t.comments) > 0
);
} else if (filters.readFilter === 'read') {
result = result.filter(
(t) => getTaskUnreadCount(readState, t.teamName, t.id, t.comments) === 0
);
}
result = applySearch(result, searchQuery);
// Archive filtering
@ -342,7 +358,7 @@ export const GlobalTaskList = ({
selectedProjectPath,
filters.statusIds,
filters.teamName,
filters.unreadOnly,
filters.readFilter,
searchQuery,
readState,
showArchived,
@ -372,7 +388,10 @@ export const GlobalTaskList = ({
[filtered, taskLocalState]
);
const sortedFlat = useMemo(() => applySortMode(normalTasks, sortMode), [normalTasks, sortMode]);
const sortedFlat = useMemo(
() => applySortMode(normalTasks, sortMode, readState),
[normalTasks, sortMode, readState]
);
const grouped = useMemo(() => groupTasksByDate(normalTasks), [normalTasks]);
const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]);
const projectGroups = useMemo(() => groupTasksByProject(normalTasks), [normalTasks]);

View file

@ -147,7 +147,7 @@ export const SidebarTaskItem = ({
return (
<button
type="button"
className={`flex w-full cursor-pointer flex-col justify-center border-b px-2 py-1.5 text-left transition-colors hover:bg-surface-raised ${task.teamDeleted ? 'opacity-50' : ''}`}
className={`flex w-full cursor-pointer flex-col justify-center border-b px-2 py-1.5 text-left transition-colors hover:bg-surface-raised ${unreadCount > 0 ? 'bg-blue-500/[0.03]' : ''} ${task.teamDeleted ? 'opacity-50' : ''}`}
style={{ borderColor: 'var(--color-border)' }}
onClick={() => {
if (!isRenaming) {
@ -200,6 +200,14 @@ export const SidebarTaskItem = ({
style={{ color: 'var(--color-text-muted)' }}
>
<StatusIcon className={`mr-1.5 inline-block size-3 align-[-1px] ${cfg.color}`} />
{unreadCount > 0 &&
(unreadCount === 1 ? (
<span className="mr-1 inline-block size-1.5 rounded-full bg-blue-400 align-middle" />
) : (
<span className="mr-1 inline-flex size-3.5 items-center justify-center rounded-full bg-blue-500 align-middle text-[8px] font-bold leading-none text-white">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
))}
{displaySubject}
{task.reviewState === 'needsFix' && (
<span
@ -208,12 +216,6 @@ export const SidebarTaskItem = ({
{REVIEW_STATE_DISPLAY.needsFix.label}
</span>
)}
{unreadCount > 0 && (
<span
className="ml-1.5 inline-block size-1.5 rounded-full bg-blue-400 align-middle"
title={`${unreadCount} unread`}
/>
)}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>

View file

@ -1,10 +1,23 @@
import { useEffect, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Combobox } from '@renderer/components/ui/combobox';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Filter } from 'lucide-react';
import { STATUS_OPTIONS, type TaskFiltersState, type TaskStatusFilterId } from './taskFiltersState';
import {
STATUS_OPTIONS,
type ReadFilter,
type TaskFiltersState,
type TaskStatusFilterId,
} from './taskFiltersState';
const READ_FILTER_OPTIONS: { value: ReadFilter; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'unread', label: 'Unread' },
{ value: 'read', label: 'Read' },
];
interface TaskFiltersPopoverProps {
open: boolean;
@ -23,28 +36,39 @@ export const TaskFiltersPopover = ({
onFiltersChange,
onApply,
}: TaskFiltersPopoverProps): React.JSX.Element => {
// Draft state — all changes accumulate here and only commit on Apply
const [draft, setDraft] = useState<TaskFiltersState>(filters);
// Reset draft when popover opens
useEffect(() => {
if (open) {
setDraft(filters);
}
}, [open, filters]);
const allSelected =
STATUS_OPTIONS.length > 0 && STATUS_OPTIONS.every((opt) => filters.statusIds.has(opt.id));
STATUS_OPTIONS.length > 0 && STATUS_OPTIONS.every((opt) => draft.statusIds.has(opt.id));
const handleSelectAll = (): void => {
if (allSelected) {
onFiltersChange({ ...filters, statusIds: new Set() });
setDraft({ ...draft, statusIds: new Set() });
} else {
onFiltersChange({
...filters,
setDraft({
...draft,
statusIds: new Set(STATUS_OPTIONS.map((o) => o.id)),
});
}
};
const toggleStatus = (id: TaskStatusFilterId): void => {
const next = new Set(filters.statusIds);
const next = new Set(draft.statusIds);
if (next.has(id)) next.delete(id);
else next.add(id);
onFiltersChange({ ...filters, statusIds: next });
setDraft({ ...draft, statusIds: next });
};
const handleApply = (): void => {
onFiltersChange(draft);
onApply();
onOpenChange(false);
};
@ -79,7 +103,7 @@ export const TaskFiltersPopover = ({
className="flex cursor-pointer items-center gap-2 text-[12px] text-text"
>
<Checkbox
checked={filters.statusIds.has(opt.id)}
checked={draft.statusIds.has(opt.id)}
onCheckedChange={() => toggleStatus(opt.id)}
style={{ '--color-accent': opt.color } as React.CSSProperties}
/>
@ -100,10 +124,10 @@ export const TaskFiltersPopover = ({
{ value: '__all__', label: 'All teams' },
...teams.map((t) => ({ value: t.teamName, label: t.displayName })),
]}
value={filters.teamName ?? '__all__'}
value={draft.teamName ?? '__all__'}
onValueChange={(v) =>
onFiltersChange({
...filters,
setDraft({
...draft,
teamName: v === '__all__' ? null : v,
})
}
@ -114,16 +138,33 @@ export const TaskFiltersPopover = ({
/>
</div>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Checkbox is a custom component wrapping native input */}
<label className="flex cursor-pointer items-center gap-2 text-[12px] text-text">
<Checkbox
checked={filters.unreadOnly}
onCheckedChange={(checked) =>
onFiltersChange({ ...filters, unreadOnly: checked === true })
}
/>
Tasks with unread comments
</label>
<div>
<span className="mb-1.5 block text-[11px] font-semibold text-text-secondary">
Comments
</span>
<div className="flex rounded-md border border-[var(--color-border)]">
{READ_FILTER_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={`flex-1 px-2 py-1 text-[11px] font-medium transition-colors first:rounded-l-[5px] last:rounded-r-[5px] ${
draft.readFilter === opt.value
? 'bg-[var(--color-surface-raised)] text-text'
: 'text-text-muted hover:text-text-secondary'
}`}
onClick={() =>
setDraft({
...draft,
readFilter: opt.value,
unreadOnly: opt.value === 'unread',
})
}
>
{opt.label}
</button>
))}
</div>
</div>
<Button
type="button"

View file

@ -20,16 +20,21 @@ export const STATUS_OPTIONS: { id: TaskStatusFilterId; label: string; color: str
{ id: 'approved', label: 'APPROVED', color: '#16a34a' },
];
export type ReadFilter = 'all' | 'unread' | 'read';
export interface TaskFiltersState {
statusIds: Set<TaskStatusFilterId>;
teamName: string | null;
/** @deprecated Use readFilter instead */
unreadOnly: boolean;
readFilter: ReadFilter;
}
export const defaultTaskFiltersState = (): TaskFiltersState => ({
statusIds: new Set(STATUS_OPTIONS.map((o) => o.id)),
teamName: null,
unreadOnly: false,
readFilter: 'all',
});
export function taskMatchesStatus(

View file

@ -1244,14 +1244,24 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
)}
>
{data.config.projectPath && (
<span
className="flex items-center gap-1 text-[11px] text-[var(--color-text-secondary)]"
title={data.config.projectPath}
>
<span className="flex items-center gap-1 text-[11px] text-[var(--color-text-secondary)]">
<FolderOpen size={11} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="max-w-60 truncate font-mono">
{formatProjectPath(data.config.projectPath)}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="max-w-60 truncate font-mono">
{data.config.projectPath
.replace(/\\/g, '/')
.split('/')
.filter(Boolean)
.pop() ?? data.config.projectPath}
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
<span className="font-mono text-xs">
{formatProjectPath(data.config.projectPath)}
</span>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button

View file

@ -1,3 +1,5 @@
import { useState } from 'react';
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
@ -8,12 +10,15 @@ import {
displayMemberName,
} from '@renderer/utils/memberHelpers';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { ChevronRight } from 'lucide-react';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
interface ActiveTasksBlockProps {
members: ResolvedTeamMember[];
tasks: TeamTaskWithKanban[];
/** Start collapsed (e.g. when rendered inside the sidebar where MemberList already shows status). */
defaultCollapsed?: boolean;
onMemberClick?: (member: ResolvedTeamMember) => void;
onTaskClick?: (task: TeamTaskWithKanban) => void;
}
@ -28,10 +33,12 @@ interface ActivityEntry {
export const ActiveTasksBlock = ({
members,
tasks,
defaultCollapsed = false,
onMemberClick,
onTaskClick,
}: ActiveTasksBlockProps): React.JSX.Element | null => {
const { isLight } = useTheme();
const [collapsed, setCollapsed] = useState(defaultCollapsed);
const colorMap = buildMemberColorMap(members);
const taskMap = new Map(tasks.map((t) => [t.id, t]));
@ -63,100 +70,115 @@ export const ActiveTasksBlock = ({
return (
<div className="mb-3 space-y-1.5">
<p className="text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
In progress
</p>
{entries.map(({ member, task, taskId, kind }) => {
const colors = getTeamColorSet(colorMap.get(member.name) ?? '');
const roleLabel = formatAgentRole(
member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined)
);
const dotPing = kind === 'reviewing' ? 'bg-amber-400' : 'bg-emerald-400';
const dotSolid = kind === 'reviewing' ? 'bg-amber-500' : 'bg-emerald-500';
const activityLabel = kind === 'reviewing' ? 'reviewing' : 'working on';
<button
type="button"
className="flex w-full items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
onClick={() => setCollapsed((v) => !v)}
aria-label={collapsed ? 'Expand in progress' : 'Collapse in progress'}
>
<ChevronRight
size={10}
className={`shrink-0 transition-transform duration-150 ${collapsed ? '' : 'rotate-90'}`}
/>
<span>In progress</span>
{collapsed && (
<span className="rounded-full bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] font-medium tabular-nums leading-none text-[var(--color-text-muted)]">
{entries.length}
</span>
)}
</button>
{!collapsed &&
entries.map(({ member, task, taskId, kind }) => {
const colors = getTeamColorSet(colorMap.get(member.name) ?? '');
const roleLabel = formatAgentRole(
member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined)
);
const dotPing = kind === 'reviewing' ? 'bg-amber-400' : 'bg-emerald-400';
const dotSolid = kind === 'reviewing' ? 'bg-amber-500' : 'bg-emerald-500';
const activityLabel = kind === 'reviewing' ? 'reviewing' : 'working on';
return (
<article
key={`${member.name}-${taskId}`}
className="activity-card-enter-animate 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 inline-flex shrink-0">
<img
src={agentAvatarUrl(member.name, 24)}
alt=""
className="size-5 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
/>
<span className="absolute -bottom-0.5 -right-0.5 flex size-2.5">
<span
className={`absolute inline-flex size-full animate-ping rounded-full ${dotPing} opacity-70`}
return (
<article
key={`${member.name}-${taskId}`}
className="activity-card-enter-animate 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 inline-flex shrink-0">
<img
src={agentAvatarUrl(member.name, 24)}
alt=""
className="size-5 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
/>
<span className={`relative inline-flex size-full rounded-full ${dotSolid}`} />
<span className="absolute -bottom-0.5 -right-0.5 flex size-2.5">
<span
className={`absolute inline-flex size-full animate-ping rounded-full ${dotPing} opacity-70`}
/>
<span className={`relative inline-flex size-full rounded-full ${dotSolid}`} />
</span>
</span>
</span>
{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: getThemedBadge(colors, isLight),
color: colors.text,
border: `1px solid ${colors.border}40`,
}}
onClick={() => onMemberClick(member)}
>
{displayMemberName(member.name)}
</button>
) : (
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={{
backgroundColor: getThemedBadge(colors, isLight),
color: colors.text,
border: `1px solid ${colors.border}40`,
}}
>
{displayMemberName(member.name)}
</span>
)}
{roleLabel ? (
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{roleLabel}
</span>
) : null}
<span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{activityLabel}
</span>
{task &&
(onTaskClick ? (
{onMemberClick ? (
<button
type="button"
className="min-w-0 flex-1 truncate rounded px-1.5 py-0.5 text-left 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}
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: getThemedBadge(colors, isLight),
color: colors.text,
border: `1px solid ${colors.border}40`,
}}
onClick={() => onMemberClick(member)}
>
{formatTaskDisplayLabel(task)} {task.subject}
{displayMemberName(member.name)}
</button>
) : (
<span
className="min-w-0 flex-1 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}
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={{
backgroundColor: getThemedBadge(colors, isLight),
color: colors.text,
border: `1px solid ${colors.border}40`,
}}
>
{formatTaskDisplayLabel(task)} {task.subject}
{displayMemberName(member.name)}
</span>
))}
</div>
</article>
);
})}
)}
{roleLabel ? (
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{roleLabel}
</span>
) : null}
<span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{activityLabel}
</span>
{task &&
(onTaskClick ? (
<button
type="button"
className="min-w-0 flex-1 truncate rounded px-1.5 py-0.5 text-left 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}
>
{formatTaskDisplayLabel(task)} {task.subject}
</button>
) : (
<span
className="min-w-0 flex-1 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}
>
{formatTaskDisplayLabel(task)} {task.subject}
</span>
))}
</div>
</article>
);
})}
</div>
);
};

View file

@ -337,6 +337,7 @@ export const MessagesPanel = memo(function MessagesPanel({
tasks={tasks}
messages={messages}
pendingRepliesByMember={pendingRepliesByMember}
position="inline"
onMemberClick={onMemberClick}
onTaskClick={onTaskClick}
/>
@ -514,9 +515,10 @@ export const MessagesPanel = memo(function MessagesPanel({
tasks={tasks}
messages={messages}
pendingRepliesByMember={pendingRepliesByMember}
position="sidebar"
onMemberClick={onMemberClick}
onTaskClick={onTaskClick}
/>
/>{' '}
</div>
<ActivityTimeline
messages={filteredMessages}

View file

@ -13,6 +13,8 @@ interface StatusBlockProps {
tasks: TeamTaskWithKanban[];
messages: InboxMessage[];
pendingRepliesByMember: Record<string, number>;
/** Where the Messages panel is rendered — 'sidebar' hides "In progress" (already visible in MemberList). */
position?: 'sidebar' | 'inline';
onMemberClick?: (member: ResolvedTeamMember) => void;
onTaskClick?: (task: TeamTaskWithKanban) => void;
}
@ -28,6 +30,7 @@ export const StatusBlock = ({
tasks,
messages,
pendingRepliesByMember,
position,
onMemberClick,
onTaskClick,
}: StatusBlockProps): React.JSX.Element | null => {
@ -92,6 +95,7 @@ export const StatusBlock = ({
<ActiveTasksBlock
members={members}
tasks={tasks}
defaultCollapsed={position === 'sidebar'}
onMemberClick={onMemberClick}
onTaskClick={onTaskClick}
/>

View file

@ -73,7 +73,8 @@ export const TerminalLogPanel = ({
if (!term) return;
for (let i = writtenRef.current; i < chunks.length; i++) {
term.write(chunks[i]);
// xterm requires \r\n for proper line breaks; normalize bare \n from process output
term.write(chunks[i].replace(/\r?\n/g, '\r\n'));
}
writtenRef.current = chunks.length;
}, [chunks]);

View file

@ -48,10 +48,6 @@ export function useTiptapEditor({
editable,
shouldRerenderOnTransaction: false, // v3 performance — toolbar использует useEditorState
autofocus: autoFocus ? 'end' : false,
enableContentCheck: true,
onContentError: ({ error }) => {
console.error('[TiptapEditor] Content error:', error);
},
onUpdate: ({ editor: e }) => {
if (isProgrammaticUpdate.current) return;
try {

View file

@ -246,7 +246,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
'leave a short task comment on that waiting task right away with the reason and your best ETA, keep it in pending/TODO'
);
expect(prompt).toContain(
'Direct messages to your team lead are only for urgent attention, no-task situations, or when the lead explicitly asked for a direct reply.'
'Beyond task-completion pings, direct messages to your team lead are only for urgent attention, no-task situations, or when the lead explicitly asked for a direct reply.'
);
expect(prompt).toContain(
'do NOT send a duplicate SendMessage to the lead with the same content unless you need urgent non-task attention.'