fix: enhance task comment functionality and improve UI interactions

- Updated the addTaskComment method to include the author as 'user' for better context in task comments.
- Enhanced error handling in TeamProvisioningService during MCP config file writing to ensure proper cleanup on failure.
- Improved user interface elements in TeamDetailView and AddMemberDialog for better accessibility and responsiveness.
- Refined mention handling in MentionableTextarea to allow for smoother user interactions with task references.
- Adjusted CSS styles for better visual contrast in light theme, enhancing overall user experience.
This commit is contained in:
iliya 2026-03-07 21:22:49 +02:00
parent c2d0a20811
commit bd96e3672b
10 changed files with 130 additions and 27 deletions

View file

@ -1,4 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
import { pathToFileURL } from 'node:url';
import { FastMCP } from 'fastmcp'; import { FastMCP } from 'fastmcp';
import { registerTools } from './tools'; import { registerTools } from './tools';
@ -14,7 +16,7 @@ export function createServer() {
return server; return server;
} }
if (import.meta.url === `file://${process.argv[1]}`) { if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const server = createServer(); const server = createServer();
void server.start({ void server.start({
transportType: 'stdio', transportType: 'stdio',

View file

@ -999,6 +999,7 @@ export class TeamDataService {
): Promise<TaskComment> { ): Promise<TaskComment> {
const controller = this.getController(teamName); const controller = this.getController(teamName);
const addResult = controller.tasks.addTaskComment(taskId, { const addResult = controller.tasks.addTaskComment(taskId, {
from: 'user',
text, text,
attachments, attachments,
}) as { task?: TeamTask; comment?: TaskComment }; }) as { task?: TeamTask; comment?: TaskComment };

View file

@ -477,14 +477,15 @@ function buildTaskStatusProtocol(teamName: string): string {
{ teamName: "${teamName}", taskId: "<taskId>", text: "<summary of your finding or decision>", from: "<your-name>" } { teamName: "${teamName}", taskId: "<taskId>", text: "<summary of your finding or decision>", from: "<your-name>" }
Do NOT comment on trivial coordination messages. Only comment when the information is valuable context for the task. Do NOT comment on trivial coordination messages. Only comment when the information is valuable context for the task.
8. When sending a message about a specific task, include its short display label like #<displayId> in your SendMessage summary field for traceability. 8. When sending a message about a specific task, include its short display label like #<displayId> in your SendMessage summary field for traceability.
9. Review workflow clarity (IMPORTANT): 9. In ALL human-facing or teammate-facing message text, when you mention a task reference, ALWAYS write it with a leading # (for example: #abcd1234, not abcd1234 or "task abcd1234").
10. Review workflow clarity (IMPORTANT):
- The work task (e.g. #1) is the thing that must end up APPROVED after review. - The work task (e.g. #1) is the thing that must end up APPROVED after review.
- If you are reviewing work for task #X, run review_approve/review_request_changes on #X (the work task). - If you are reviewing work for task #X, run review_approve/review_request_changes on #X (the work task).
- Do NOT approve a separate "review task" (e.g. #2 created just to ask for a review) that will put the wrong task into APPROVED. - Do NOT approve a separate "review task" (e.g. #2 created just to ask for a review) that will put the wrong task into APPROVED.
- Typical flow: - Typical flow:
a) Owner finishes work on #X -> task_complete #X a) Owner finishes work on #X -> task_complete #X
b) Reviewer accepts -> review_approve #X b) Reviewer accepts -> review_approve #X
10. CLARIFICATION PROTOCOL (CRITICAL MANDATORY): 11. CLARIFICATION PROTOCOL (CRITICAL MANDATORY):
When you are blocked and need information to continue a task, you MUST do BOTH steps below skipping the MCP update breaks the task board: When you are blocked and need information to continue a task, you MUST do BOTH steps below skipping the MCP update breaks the task board:
a) STEP 1 FIRST, set the clarification flag with MCP tool task_set_clarification: a) STEP 1 FIRST, set the clarification flag with MCP tool task_set_clarification:
{ teamName: "${teamName}", taskId: "<taskId>", value: "lead" } { teamName: "${teamName}", taskId: "<taskId>", value: "lead" }
@ -494,7 +495,7 @@ function buildTaskStatusProtocol(teamName: string): string {
If the lead replies via SendMessage instead, clear the flag yourself once you have the answer: If the lead replies via SendMessage instead, clear the flag yourself once you have the answer:
{ teamName: "${teamName}", taskId: "<taskId>", value: "clear" } { teamName: "${teamName}", taskId: "<taskId>", value: "clear" }
d) Do NOT set clarification to "user" yourself only the team lead escalates to the user. d) Do NOT set clarification to "user" yourself only the team lead escalates to the user.
11. DEPENDENCY AWARENESS: 12. DEPENDENCY AWARENESS:
When your task has blockedBy dependencies, check if they are completed before starting. When your task has blockedBy dependencies, check if they are completed before starting.
When you complete a task that blocks others, mention this in your completion message so blocked teammates can proceed. When you complete a task that blocks others, mention this in your completion message so blocked teammates can proceed.
Failure to follow this protocol means the task board will show incorrect status.`); Failure to follow this protocol means the task board will show incorrect status.`);
@ -677,6 +678,7 @@ ${AGENT_BLOCK_CLOSE}
- any node/bash commands - any node/bash commands
- internal file paths (~/.claude/teams/, etc.) - internal file paths (~/.claude/teams/, etc.)
- instructions to run commands in terminal - instructions to run commands in terminal
- task references without a leading # (for example write #abcd1234, not abcd1234)
Instead, describe the action in human-friendly language (e.g. "Task #6 is complete." instead of showing a command to mark it complete). If you need to update task status, do it YOURSELF never ask the user to run a command. Instead, describe the action in human-friendly language (e.g. "Task #6 is complete." instead of showing a command to mark it complete). If you need to update task status, do it YOURSELF never ask the user to run a command.
- CRITICAL: When processing relayed inbox messages, your text output is shown to the user. Do NOT wrap your entire response in an agent-only block. If you need agent-only instructions, put them in a separate block and include a brief human-readable summary outside of it (e.g. "Delegated task to carol." or "Acknowledged, no action needed.").`; - CRITICAL: When processing relayed inbox messages, your text output is shown to the user. Do NOT wrap your entire response in an agent-only block. If you need agent-only instructions, put them in a separate block and include a brief human-readable summary outside of it (e.g. "Delegated task to carol." or "Acknowledged, no action needed.").`;
} }
@ -1855,7 +1857,14 @@ export class TeamProvisioningService {
const prompt = buildProvisioningPrompt(request); const prompt = buildProvisioningPrompt(request);
let child: ReturnType<typeof spawn>; let child: ReturnType<typeof spawn>;
const { env: shellEnv } = await this.buildProvisioningEnv(); const { env: shellEnv } = await this.buildProvisioningEnv();
const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(); let mcpConfigPath: string;
try {
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile();
} catch (error) {
this.runs.delete(runId);
this.activeByTeam.delete(request.teamName);
throw error;
}
const spawnArgs = [ const spawnArgs = [
'--input-format', '--input-format',
'stream-json', 'stream-json',
@ -2188,7 +2197,15 @@ export class TeamProvisioningService {
); );
let child: ReturnType<typeof spawn>; let child: ReturnType<typeof spawn>;
const { env: shellEnv } = await this.buildProvisioningEnv(); const { env: shellEnv } = await this.buildProvisioningEnv();
const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(); let mcpConfigPath: string;
try {
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile();
} catch (error) {
this.runs.delete(runId);
this.activeByTeam.delete(request.teamName);
await this.restorePrelaunchConfig(request.teamName);
throw error;
}
const launchArgs = [ const launchArgs = [
'--input-format', '--input-format',
'stream-json', 'stream-json',

View file

@ -955,7 +955,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
)} )}
<div <div
className="relative mb-3 overflow-hidden rounded-lg border border-[var(--color-border)] px-4 py-3" className="relative -mx-4 -mt-4 mb-3 overflow-hidden border-b border-[var(--color-border)] px-4 py-3"
style={ style={
headerColorSet headerColorSet
? { borderLeftWidth: '3px', borderLeftColor: headerColorSet.border } ? { borderLeftWidth: '3px', borderLeftColor: headerColorSet.border }
@ -964,7 +964,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
> >
{headerColorSet ? ( {headerColorSet ? (
<div <div
className="pointer-events-none absolute inset-0 z-0 rounded-lg" className="pointer-events-none absolute inset-0 z-0"
style={{ backgroundColor: getThemedBadge(headerColorSet, isLight) }} style={{ backgroundColor: getThemedBadge(headerColorSet, isLight) }}
/> />
) : null} ) : null}

View file

@ -2,9 +2,8 @@ import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { agentAvatarUrl, buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { Loader2 } from 'lucide-react';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
@ -51,14 +50,18 @@ export const ActiveTasksBlock = ({
}} }}
> >
<div className="flex items-center gap-2 px-3 py-2"> <div className="flex items-center gap-2 px-3 py-2">
<span className="relative flex size-2 shrink-0"> <span className="relative inline-flex shrink-0">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-70" /> <img
<span className="relative inline-flex size-2 rounded-full bg-emerald-500" /> 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 h-2.5 w-2.5">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-70" />
<span className="relative inline-flex size-full rounded-full bg-emerald-500" />
</span>
</span> </span>
<Loader2
className="size-3.5 shrink-0 animate-spin"
style={{ color: colors.border }}
/>
{onMemberClick ? ( {onMemberClick ? (
<button <button
type="button" type="button"

View file

@ -126,7 +126,7 @@ export const AddMemberDialog = ({
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md"> <DialogContent className="max-w-3xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Add Member</DialogTitle> <DialogTitle>Add Member</DialogTitle>
<DialogDescription>Add a new member to {teamName}</DialogDescription> <DialogDescription>Add a new member to {teamName}</DialogDescription>

View file

@ -488,9 +488,11 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
setMergedIndex((prev) => (prev - 1 + allSuggestions.length) % allSuggestions.length); setMergedIndex((prev) => (prev - 1 + allSuggestions.length) % allSuggestions.length);
break; break;
case 'Enter': case 'Enter':
e.preventDefault(); if (!e.shiftKey) {
if (allSuggestions[mergedIndex]) { e.preventDefault();
handleMergedSelect(allSuggestions[mergedIndex]); if (allSuggestions[mergedIndex]) {
handleMergedSelect(allSuggestions[mergedIndex]);
}
} }
break; break;
case 'Escape': case 'Escape':
@ -502,9 +504,18 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
[isOpen, allSuggestions, mergedIndex, handleMergedSelect, dismiss] [isOpen, allSuggestions, mergedIndex, handleMergedSelect, dismiss]
); );
// Composed key handler: Mod+Enter submit → chip logic → mention logic // Composed key handler: mention logic first (when open) → Mod+Enter submit → chip logic → mention fallback
const composedHandleKeyDown = React.useCallback( const composedHandleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => { (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// When mention dropdown is open, let mention handler consume Enter/Arrow keys first
if (isOpen && effectiveSuggestions.length > 0) {
if (enableFiles) {
fileMentionHandleKeyDown(e);
} else {
mentionHandleKeyDown(e);
}
if (e.defaultPrevented) return;
}
// Enter (without Shift) → submit; Shift+Enter → newline // Enter (without Shift) → submit; Shift+Enter → newline
if (e.key === 'Enter' && !e.shiftKey && onModEnter) { if (e.key === 'Enter' && !e.shiftKey && onModEnter) {
e.preventDefault(); e.preventDefault();
@ -512,7 +523,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
return; return;
} }
handleChipKeyDown(e); handleChipKeyDown(e);
if (!e.defaultPrevented) { if (!e.defaultPrevented && !isOpen) {
if (enableFiles) { if (enableFiles) {
fileMentionHandleKeyDown(e); fileMentionHandleKeyDown(e);
} else { } else {
@ -520,7 +531,15 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
} }
} }
}, },
[onModEnter, handleChipKeyDown, enableFiles, fileMentionHandleKeyDown, mentionHandleKeyDown] [
onModEnter,
handleChipKeyDown,
enableFiles,
fileMentionHandleKeyDown,
mentionHandleKeyDown,
isOpen,
effectiveSuggestions.length,
]
); );
// --- Chip reconciliation on text change --- // --- Chip reconciliation on text change ---

View file

@ -298,8 +298,10 @@ export function useMentionDetection({
); );
break; break;
case 'Enter': case 'Enter':
e.preventDefault(); if (!e.shiftKey) {
selectSuggestion(filteredSuggestions[selectedIndex]); e.preventDefault();
selectSuggestion(filteredSuggestions[selectedIndex]);
}
break; break;
case 'Escape': case 'Escape':
e.preventDefault(); e.preventDefault();

View file

@ -233,7 +233,7 @@
/* Light theme overrides - Warm neutral palette for eye comfort */ /* Light theme overrides - Warm neutral palette for eye comfort */
:root.light { :root.light {
--color-surface: #f9f9f7; /* Warm off-white (not pure white) */ --color-surface: #f9f9f7; /* Warm off-white (not pure white) */
--color-surface-raised: #f0efed; /* Warm raised surface, clearly distinct */ --color-surface-raised: #e8e6e3; /* Warm raised surface — stronger contrast for visible hover */
--color-surface-overlay: #e8e7e4; /* Warm overlay */ --color-surface-overlay: #e8e7e4; /* Warm overlay */
--color-surface-sidebar: #f1f0ee; /* Warm sidebar, distinct from main */ --color-surface-sidebar: #f1f0ee; /* Warm sidebar, distinct from main */
--color-border: #d5d3cf; /* Warm neutral border */ --color-border: #d5d3cf; /* Warm neutral border */

View file

@ -118,6 +118,65 @@ describe('TeamDataService', () => {
await expect(service.reconcileTeamArtifacts('my-team')).rejects.toThrow('reconcile failed'); await expect(service.reconcileTeamArtifacts('my-team')).rejects.toThrow('reconcile failed');
}); });
it('writes UI task comments with author user', async () => {
const addTaskComment = vi.fn(() => ({
comment: {
id: 'comment-1',
author: 'user',
text: 'Need clarification',
createdAt: '2026-03-07T20:00:00.000Z',
type: 'regular',
},
task: {
id: 'task-1',
subject: 'Investigate',
status: 'pending',
owner: 'team-lead',
},
}));
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 () => []),
} as never,
{
listInboxNames: vi.fn(async () => []),
getMessages: vi.fn(async () => []),
} as never,
{} as never,
{} 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,
{} as never,
{} as never,
() =>
({
tasks: {
addTaskComment,
setNeedsClarification: vi.fn(),
},
}) as never
);
await service.addTaskComment('my-team', 'task-1', 'Need clarification');
expect(addTaskComment).toHaveBeenCalledWith('task-1', {
from: 'user',
text: 'Need clarification',
attachments: undefined,
});
});
it('includes projectPath from config when creating a task', async () => { it('includes projectPath from config when creating a task', async () => {
const createTaskMock = vi.fn((task) => task); const createTaskMock = vi.fn((task) => task);