From c5c41d2a0d53eac84e7701e489e5ca73b5b7f7f0 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 11 Mar 2026 12:54:04 +0200 Subject: [PATCH] feat: enhance task creation and management features - Added new optional parameters 'createdBy' and 'from' to the task creation function for better tracking of task origins. - Updated task execution logic to include the new parameters, improving task metadata handling. - Enhanced tests to validate the new parameters and ensure correct task creation behavior. - Refactored related components to accommodate the changes in task management, ensuring a seamless user experience. --- mcp-server/src/tools/taskTools.ts | 32 +++- mcp-server/test/tools.test.ts | 11 ++ .../services/team/TeamProvisioningService.ts | 47 +++--- src/renderer/components/chat/ChatHistory.tsx | 52 +++---- .../chat/SessionContextPanel/index.tsx | 5 +- .../chat/SessionContextPanel/types.ts | 2 + src/renderer/components/layout/MoreMenu.tsx | 12 +- src/renderer/components/layout/PaneView.tsx | 1 - src/renderer/components/layout/Sidebar.tsx | 12 +- src/renderer/components/layout/TabBar.tsx | 24 +-- .../components/layout/TabBarActions.tsx | 44 +++--- src/renderer/components/layout/TabBarRow.tsx | 38 ++--- .../components/layout/TabbedLayout.tsx | 25 +--- .../components/sidebar/SidebarTaskItem.tsx | 85 ++++++----- .../components/team/TeamDetailView.tsx | 107 ++++++------- .../components/team/activity/ActivityItem.tsx | 11 +- .../team/dialogs/StatusHistoryTimeline.tsx | 17 ++- .../components/team/kanban/KanbanBoard.tsx | 73 +++++++-- .../team/kanban/KanbanSortPopover.tsx | 140 ++++++++++++++++++ 19 files changed, 488 insertions(+), 250 deletions(-) create mode 100644 src/renderer/components/team/kanban/KanbanSortPopover.tsx diff --git a/mcp-server/src/tools/taskTools.ts b/mcp-server/src/tools/taskTools.ts index 94bdaf07..7d719197 100644 --- a/mcp-server/src/tools/taskTools.ts +++ b/mcp-server/src/tools/taskTools.ts @@ -20,23 +20,39 @@ export function registerTaskTools(server: Pick) { subject: z.string().min(1), description: z.string().optional(), owner: z.string().optional(), + createdBy: z.string().optional(), + from: z.string().optional(), blockedBy: z.array(z.string().min(1)).optional(), related: z.array(z.string().min(1)).optional(), prompt: z.string().optional(), startImmediately: z.boolean().optional(), }), - execute: async ({ teamName, claudeDir, subject, description, owner, blockedBy, related, prompt, startImmediately }) => { + execute: async ({ + teamName, + claudeDir, + subject, + description, + owner, + createdBy, + from, + blockedBy, + related, + prompt, + startImmediately, + }) => { const controller = getController(teamName, claudeDir); return await Promise.resolve( jsonTextContent( controller.tasks.createTask({ - subject, - ...(description ? { description } : {}), - ...(owner ? { owner } : {}), - ...(blockedBy?.length ? { 'blocked-by': blockedBy.join(',') } : {}), - ...(related?.length ? { related: related.join(',') } : {}), - ...(prompt ? { prompt } : {}), - ...(startImmediately !== undefined ? { startImmediately } : {}), + subject, + ...(description ? { description } : {}), + ...(owner ? { owner } : {}), + ...(createdBy ? { createdBy } : {}), + ...(!createdBy && from ? { from } : {}), + ...(blockedBy?.length ? { 'blocked-by': blockedBy.join(',') } : {}), + ...(related?.length ? { related: related.join(',') } : {}), + ...(prompt ? { prompt } : {}), + ...(startImmediately !== undefined ? { startImmediately } : {}), }) ) ); diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index 72e79e79..bd0e76ba 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -109,9 +109,11 @@ describe('agent-teams-mcp tools', () => { teamName, subject: 'Review MCP adapter', owner: 'alice', + createdBy: 'ui-fixer', }) ); expect(createdTask.status).toBe('pending'); + expect(createdTask.historyEvents?.[0]?.actor).toBe('ui-fixer'); const listedTasks = parseJsonToolResult( await getTool('task_list').execute({ @@ -558,6 +560,15 @@ describe('agent-teams-mcp tools', () => { }).success ).toBe(false); + expect( + getTool('task_create').parameters?.safeParse({ + teamName: 'demo', + claudeDir: '/tmp/demo', + subject: 'Created by schema', + createdBy: 'ui-fixer', + }).success + ).toBe(true); + expect( getTool('process_register').parameters?.safeParse({ teamName: 'demo', diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 16e99d2e..65465ae1 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -399,33 +399,38 @@ function buildTaskStatusProtocol(teamName: string): string { - If a board/task snapshot shows a canonical taskId, prefer using that exact value in MCP tool calls. - task_briefing may show short display labels like #abcd1234; MCP task tools also accept that short task ref. - Human-facing summaries should use the short display label like #abcd1234 for readability. -1. Use MCP tool task_start to mark task started: +1. If you are about to do implementation/fix work on a task yourself, make sure the owner reflects the actual implementer: + - If the task is unassigned or assigned to someone else, FIRST reassign it to yourself with MCP tool task_set_owner: + { teamName: "${teamName}", taskId: "", owner: "" } + - Do this only when you are genuinely taking over the work. + - Reviewing, approving, or leaving comments does NOT require changing ownership. +2. Use MCP tool task_start to mark task started: { teamName: "${teamName}", taskId: "" } - Start the task ONLY when you are actually beginning work on it. - Do NOT start multiple tasks at once unless the team lead explicitly directs parallel work. -2. Use MCP tool task_complete BEFORE sending your final reply: +3. Use MCP tool task_complete BEFORE sending your final reply: { teamName: "${teamName}", taskId: "" } -3. If you are asked to review and the task is accepted, move it to APPROVED (not DONE) with MCP tool review_approve: +4. If you are asked to review and the task is accepted, move it to APPROVED (not DONE) with MCP tool review_approve: { teamName: "${teamName}", taskId: "", note?: "", notifyOwner: true } -4. If review fails and changes are needed, use MCP tool review_request_changes: +5. If review fails and changes are needed, use MCP tool review_request_changes: { teamName: "${teamName}", taskId: "", comment: "" } -5. NEVER skip status updates. A task is NOT done until completed status is written. +6. NEVER skip status updates. A task is NOT done until completed status is written. - Never "bulk-complete" a batch of tasks at the end. Update status incrementally as you work. -6. To reply to a comment on a task, use MCP tool task_add_comment: +7. To reply to a comment on a task, use MCP tool task_add_comment: { teamName: "${teamName}", taskId: "", text: "", from: "" } -7. When discussing a task with a teammate and you have important findings, decisions, blockers, or progress updates — record them as a task comment: +8. When discussing a task with a teammate and you have important findings, decisions, blockers, or progress updates — record them as a task comment: { teamName: "${teamName}", taskId: "", text: "", from: "" } 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 # in your SendMessage summary field for traceability. -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): +9. When sending a message about a specific task, include its short display label like # in your SendMessage summary field for traceability. +10. 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"). +11. Review workflow clarity (IMPORTANT): - 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). - 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: a) Owner finishes work on #X -> task_complete #X b) Reviewer accepts -> review_approve #X -11. CLARIFICATION PROTOCOL (CRITICAL — MANDATORY): +12. CLARIFICATION PROTOCOL (CRITICAL — MANDATORY): When you are blocked and need information to continue a task, you MUST do ALL steps below — skipping the board update or comment breaks traceability: a) STEP 1 — FIRST, set the clarification flag with MCP tool task_set_clarification: { teamName: "${teamName}", taskId: "", value: "lead" } @@ -437,15 +442,17 @@ function buildTaskStatusProtocol(teamName: string): string { If the lead replies via SendMessage instead, clear the flag yourself once you have the answer: { teamName: "${teamName}", taskId: "", value: "clear" } e) Do NOT set clarification to "user" yourself — only the team lead escalates to the user. -12. DEPENDENCY AWARENESS: +13. DEPENDENCY AWARENESS: 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. -13. TASK QUEUE DISCIPLINE: +14. TASK QUEUE DISCIPLINE: - Use task_briefing as a compact queue view of your assigned tasks. - task_briefing may include full description/comments only for in_progress tasks; needsFix/pending/review/completed entries may be minimal on purpose. - Finish existing in_progress tasks first. - If you need more context for an in_progress task, you MAY call task_get, but it is not mandatory when task_briefing already gives enough detail. - - Before starting a needsFix or pending task, call task_get for that specific task first, then run task_start only when you truly begin. + - Before starting a needsFix or pending task, call task_get for that specific task first. + - If you are the one doing the implementation/fixes and the owner is missing or someone else, run task_set_owner to yourself immediately before task_start. + - Then run task_start only when you truly begin. - If you complete fixes for a needsFix task, mark it completed and then send it back through review_request when ready for another review pass. Failure to follow this protocol means the task board will show incorrect status.`); } @@ -485,7 +492,7 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string `IMPORTANT: The board MCP only supports these domains: task, kanban, review, message, process. There is NO "member" domain — team members are managed by spawning teammates via the Task tool, not via the board MCP.`, ``, `Task board operations — use MCP tools directly:`, - `- Create task: task_create { teamName: "${teamName}", subject: "...", description?: "...", owner?: "", blockedBy?: ["1","2"], related?: ["3"] }`, + `- Create task: task_create { teamName: "${teamName}", subject: "...", description?: "...", owner?: "", createdBy?: "", blockedBy?: ["1","2"], related?: ["3"] }`, `- Assign/reassign owner: task_set_owner { teamName: "${teamName}", taskId: "", owner: "" }`, `- Clear owner: task_set_owner { teamName: "${teamName}", taskId: "", owner: null }`, `- Start task (preferred over set-status): task_start { teamName: "${teamName}", taskId: "" }`, @@ -496,7 +503,7 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string `- Attach file to a specific comment:`, ` 1) Find commentId: task_get { teamName: "${teamName}", taskId: "" }`, ` 2) Attach: task_attach_comment_file { teamName: "${teamName}", taskId: "", commentId: "", filePath: "", mode?: "copy|link", filename?: "", mimeType?: "" }`, - `- Create with deps (blocked work MUST be pending): task_create { teamName: "${teamName}", subject: "...", owner: "", blockedBy: ["1","2"], related?: ["3"], startImmediately: false }`, + `- Create with deps (blocked work MUST be pending): task_create { teamName: "${teamName}", subject: "...", owner: "", createdBy: "", blockedBy: ["1","2"], related?: ["3"], startImmediately: false }`, `- Link dependency: task_link { teamName: "${teamName}", taskId: "", targetId: "", relationship: "blocked-by" }`, `- Link related: task_link { teamName: "${teamName}", taskId: "", targetId: "", relationship: "related" }`, `- Unlink: task_unlink { teamName: "${teamName}", taskId: "", targetId: "", relationship: "blocked-by" }`, @@ -517,6 +524,8 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string `Notification policy:`, `- Task assignment notifications are handled by the board runtime, so do NOT send a separate SendMessage for the same assignment unless you have extra context that is not already on the task.`, `- Review requests are also handled by the board runtime: review_request already notifies the reviewer, so do NOT send a second manual SendMessage for the same review request unless you are adding materially new context that is not already on the task.`, + `- Ownership must reflect the person actually doing the implementation/fix work. If someone takes over execution, update the owner immediately before they start. Do NOT leave the lead/planner as owner when another member is doing the work.`, + `- Set createdBy when creating tasks so workflow history shows who created the task.`, ``, `Clarification handling (CRITICAL — MANDATORY for correct task board state):`, `- When a teammate needs clarification (needsClarification: "lead"), reply via task comment (preferred — auto-clears the flag and wakes the owner) or SendMessage.`, @@ -851,7 +860,7 @@ function buildLaunchPrompt( When you receive that follow-up message: - Execute tasks sequentially and keep the board + user updated: - Identify the next READY task (pending, not blocked by incomplete dependencies). - - If the task is unassigned, set yourself ("${leadName}") as owner. + - If the task is unassigned or assigned to someone else but you are the one about to do the work, set yourself ("${leadName}") as owner. - If the work you are about to do is not represented on the board yet, create/update the task first before continuing. - BEFORE doing any work on a task: mark it started (in_progress). - Immediately SendMessage "user" that you started task # (what you're doing + next step). @@ -893,7 +902,9 @@ ${actionModeProtocol} Then: - If task_briefing shows any in_progress task, resume/finish those first. Call task_get only if you need more context than task_briefing already gave you. - After that, prioritize tasks marked Needs fixes after review, then normal pending tasks. - - Before you start any needsFix or pending task, call task_get for that specific task, and only then run task_start when you truly begin. + - Before you start any needsFix or pending task, call task_get for that specific task. + - 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 you have no tasks, wait for new assignments.`; }) .join('\n\n'); diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index d695f837..df86fd30 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -13,8 +13,6 @@ import { SessionContextPanel } from './SessionContextPanel/index'; /** Pixels from bottom considered "near bottom" for scroll-button visibility and auto-scroll. */ const SCROLL_THRESHOLD = 300; -/** Must match the `w-80` (320px) context panel width used in the layout below. */ -const CONTEXT_PANEL_WIDTH_PX = 320; import { computeRemainingContext, @@ -833,6 +831,28 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { style={{ backgroundColor: 'var(--color-surface)' }} >
+ {/* Context panel sidebar (left) */} + {isContextPanelVisible && allContextInjections.length > 0 && ( +
+ setContextPanelVisible(false)} + projectRoot={sessionDetail?.session?.projectPath} + onNavigateToTurn={handleNavigateToTurn} + onNavigateToTool={handleNavigateToTool} + onNavigateToUserGroup={handleNavigateToUserGroup} + totalSessionTokens={lastAiGroupTotalTokens} + sessionMetrics={sessionDetail?.metrics} + subagentCostUsd={subagentCostUsd} + onViewReport={effectiveTabId ? () => openSessionReport(effectiveTabId) : undefined} + phaseInfo={sessionPhaseInfo ?? undefined} + selectedPhase={selectedContextPhase} + onPhaseChange={setSelectedContextPhase} + side="left" + /> +
+ )} + {/* Chat content */}
{ > {/* Sticky Context button */} {allContextInjections.length > 0 && ( -
+
)} - - {/* Context panel sidebar */} - {isContextPanelVisible && allContextInjections.length > 0 && ( -
- setContextPanelVisible(false)} - projectRoot={sessionDetail?.session?.projectPath} - onNavigateToTurn={handleNavigateToTurn} - onNavigateToTool={handleNavigateToTool} - onNavigateToUserGroup={handleNavigateToUserGroup} - totalSessionTokens={lastAiGroupTotalTokens} - sessionMetrics={sessionDetail?.metrics} - subagentCostUsd={subagentCostUsd} - onViewReport={effectiveTabId ? () => openSessionReport(effectiveTabId) : undefined} - phaseInfo={sessionPhaseInfo ?? undefined} - selectedPhase={selectedContextPhase} - onPhaseChange={setSelectedContextPhase} - /> -
- )}
); diff --git a/src/renderer/components/chat/SessionContextPanel/index.tsx b/src/renderer/components/chat/SessionContextPanel/index.tsx index 2c956ef5..60754d7e 100644 --- a/src/renderer/components/chat/SessionContextPanel/index.tsx +++ b/src/renderer/components/chat/SessionContextPanel/index.tsx @@ -55,6 +55,7 @@ export const SessionContextPanel = ({ phaseInfo, selectedPhase, onPhaseChange, + side = 'left', }: Readonly): React.ReactElement => { // View mode: category sections or ranked list const [viewMode, setViewMode] = useState('category'); @@ -184,7 +185,9 @@ export const SessionContextPanel = ({ className="flex h-full flex-col" style={{ backgroundColor: COLOR_SURFACE, - borderLeft: `1px solid ${COLOR_BORDER}`, + ...(side === 'left' + ? { borderRight: `1px solid ${COLOR_BORDER}` } + : { borderLeft: `1px solid ${COLOR_BORDER}` }), }} > void; + /** Which side of the content the panel is on: left → borderRight, right → borderLeft */ + side?: 'left' | 'right'; } // ============================================================================= diff --git a/src/renderer/components/layout/MoreMenu.tsx b/src/renderer/components/layout/MoreMenu.tsx index 13775035..ccc70c57 100644 --- a/src/renderer/components/layout/MoreMenu.tsx +++ b/src/renderer/components/layout/MoreMenu.tsx @@ -10,7 +10,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useStore } from '@renderer/store'; import { triggerDownload } from '@renderer/utils/sessionExporter'; import { formatShortcut } from '@renderer/utils/stringUtils'; -import { Activity, Braces, FileText, MoreHorizontal, Search, Type } from 'lucide-react'; +import { Activity, Braces, Calendar, FileText, MoreHorizontal, Search, Type } from 'lucide-react'; import type { SessionDetail } from '@renderer/types/data'; import type { Tab } from '@renderer/types/tabs'; @@ -42,6 +42,7 @@ export const MoreMenu = ({ const openCommandPalette = useStore((s) => s.openCommandPalette); const openSessionReport = useStore((s) => s.openSessionReport); + const openSchedulesTab = useStore((s) => s.openSchedulesTab); // Close on outside click useEffect(() => { @@ -95,6 +96,15 @@ export const MoreMenu = ({ setIsOpen(false); }, }, + { + id: 'schedules', + label: 'Schedules', + icon: Calendar, + onClick: () => { + openSchedulesTab(); + setIsOpen(false); + }, + }, ]; const sessionItems: MenuItem[] = isSessionWithData diff --git a/src/renderer/components/layout/PaneView.tsx b/src/renderer/components/layout/PaneView.tsx index bd400ce9..29db5e24 100644 --- a/src/renderer/components/layout/PaneView.tsx +++ b/src/renderer/components/layout/PaneView.tsx @@ -46,7 +46,6 @@ export const PaneView = ({ paneId }: PaneViewProps): React.JSX.Element => { className="relative flex min-w-0 flex-col" style={{ width: `${pane.widthFraction * 100}%`, - paddingTop: '36px', }} onMouseDown={handleMouseDown} > diff --git a/src/renderer/components/layout/Sidebar.tsx b/src/renderer/components/layout/Sidebar.tsx index c4c60163..224f4b9b 100644 --- a/src/renderer/components/layout/Sidebar.tsx +++ b/src/renderer/components/layout/Sidebar.tsx @@ -42,12 +42,12 @@ export const Sidebar = (): React.JSX.Element => { const [isCollapseHovered, setIsCollapseHovered] = useState(false); const sidebarRef = useRef(null); - // Handle mouse move during resize + // Handle mouse move during resize (right sidebar: width = viewport - clientX) const handleMouseMove = useCallback( (e: MouseEvent) => { if (!isResizing) return; - const newWidth = e.clientX; + const newWidth = window.innerWidth - e.clientX; if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) { setWidth(newWidth); } @@ -85,18 +85,18 @@ export const Sidebar = (): React.JSX.Element => { return (
{ - )} - {/* Tab list with horizontal scroll, sortable DnD, and droppable area. Capped at 75% so the drag spacer always has room to the right. */}
{ openNotificationsTab, openTeamsTab, openExtensionsTab, - openSchedulesTab, openSettingsTab, activeTabId, openTabs, tabSessionData, + sidebarCollapsed, + toggleSidebar, } = useStore( useShallow((s) => ({ unreadCount: s.unreadCount, openNotificationsTab: s.openNotificationsTab, openTeamsTab: s.openTeamsTab, openExtensionsTab: s.openExtensionsTab, - openSchedulesTab: s.openSchedulesTab, openSettingsTab: s.openSettingsTab, activeTabId: s.activeTabId, openTabs: s.openTabs, tabSessionData: s.tabSessionData, + sidebarCollapsed: s.sidebarCollapsed, + toggleSidebar: s.toggleSidebar, })) ); // Hover states for buttons const [notificationsHover, setNotificationsHover] = useState(false); const [teamsHover, setTeamsHover] = useState(false); - const [schedulesHover, setSchedulesHover] = useState(false); const [extensionsHover, setExtensionsHover] = useState(false); const [githubHover, setGithubHover] = useState(false); const [settingsHover, setSettingsHover] = useState(false); + const [expandHover, setExpandHover] = useState(false); // Derive active tab and session detail for MoreMenu const activeTab = useMemo( @@ -95,21 +97,6 @@ export const TabBarActions = (): React.JSX.Element => { - {/* Schedules icon */} - - {/* Extensions icon */} - {/* More menu (Search, Export, Analyze) */} + {/* More menu (Search, Export, Analyze, Schedules) */} + + {/* Expand sidebar — rightmost, only when collapsed */} + {sidebarCollapsed && ( + + )}
); }; diff --git a/src/renderer/components/layout/TabBarRow.tsx b/src/renderer/components/layout/TabBarRow.tsx index ffc06214..e10f0238 100644 --- a/src/renderer/components/layout/TabBarRow.tsx +++ b/src/renderer/components/layout/TabBarRow.tsx @@ -13,6 +13,7 @@ import { Plus } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { TabBar } from './TabBar'; +import { TabBarActions } from './TabBarActions'; export const TabBarRow = (): React.JSX.Element => { const { panes, focusedPaneId, openDashboard } = useStore( @@ -67,25 +68,28 @@ export const TabBarRow = (): React.JSX.Element => {
))} + + {/* New tab button — right after last tab */} +
- {/* New tab button — right corner */} - + {/* Action buttons — right side */} +
); }; diff --git a/src/renderer/components/layout/TabbedLayout.tsx b/src/renderer/components/layout/TabbedLayout.tsx index 729c8c9a..b384c429 100644 --- a/src/renderer/components/layout/TabbedLayout.tsx +++ b/src/renderer/components/layout/TabbedLayout.tsx @@ -37,7 +37,6 @@ import { CustomTitleBar } from './CustomTitleBar'; import { PaneContainer } from './PaneContainer'; import { Sidebar } from './Sidebar'; import { DragOverlayTab } from './SortableTab'; -import { TabBarActions } from './TabBarActions'; import { TabBarRow } from './TabBarRow'; import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'; @@ -165,32 +164,16 @@ export const TabbedLayout = (): React.JSX.Element => { {/* Command Palette (Cmd+K) */} - {/* Sidebar - Task list / Sessions (280px) */} - - - {/* Content column: floating actions bar + pane content */} + {/* Content area */}
- {/* Content header with action buttons — floats over pane content */} -
- -
- - {/* Multi-pane content area — renders from top:0, behind the floating bar */}
+ + {/* Sidebar - Task list / Sessions (right side) */} +
{/* Drag overlay - semi-transparent ghost of the dragged tab */} diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index dc017ec9..42725baa 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -147,7 +147,7 @@ export const SidebarTaskItem = ({ return ( + +
+

+ {leadSessionLoading + ? 'Loading context…' + : 'Open the team lead session to view context.'} +

+
+ + )} + + )} +
- {/* Context button pinned to bottom-right of viewport */} + {/* Context button pinned to bottom-left of viewport */} {leadSessionId && (
-
-
-

- {leadSessionLoading - ? 'Loading context…' - : 'Open the team lead session to view context.'} -

-
-
- )} - - )} {editorOpen && data.config.projectPath && ( diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index e384e621..8b6a3617 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -398,8 +398,15 @@ export const ActivityItem = ({ return result; }, [strippedText, memberColorMap, teamNames, systemLabel]); - const rawSummary = - message.summary || (structured ? getStructuredMessageSummary(structured) : '') || ''; + const rawSummary = useMemo(() => { + const s = message.summary || (structured ? getStructuredMessageSummary(structured) : '') || ''; + if (s) return s; + // Fallback: use the beginning of message text as preview for plain-text messages + const plain = stripAgentBlocks(message.text).trim(); + if (!plain) return ''; + const oneLine = plain.replace(/\n+/g, ' '); + return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine; + }, [message.summary, structured, message.text]); const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]); // Noise messages: minimal inline row diff --git a/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx b/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx index ea95f9e7..1962d905 100644 --- a/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx +++ b/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx @@ -47,7 +47,7 @@ export const WorkflowTimeline = ({ events, memberColorMap }: WorkflowTimelinePro {time} - {event.actor ? ( + {shouldShowTrailingActor(event) && event.actor ? ( Created as + {event.actor ? ( + <> + by + + + ) : null} ); case 'status_changed': @@ -174,6 +185,10 @@ function dotColor(event: TaskHistoryEvent): string { } } +function shouldShowTrailingActor(event: TaskHistoryEvent): boolean { + return event.type !== 'task_created'; +} + function dotColorForStatus(status: TeamTaskStatus): string { switch (status) { case 'pending': diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index eaa1e830..fed458ca 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -23,9 +23,11 @@ import { import { KanbanColumn } from './KanbanColumn'; import { KanbanFilterPopover } from './KanbanFilterPopover'; +import { KanbanSortPopover } from './KanbanSortPopover'; import { KanbanTaskCard } from './KanbanTaskCard'; import type { KanbanFilterState } from './KanbanFilterPopover'; +import type { KanbanSortField, KanbanSortState } from './KanbanSortPopover'; import type { DragEndEvent } from '@dnd-kit/core'; import type { Session } from '@renderer/types/data'; import type { KanbanColumnId, KanbanState, ResolvedTeamMember, TeamTask } from '@shared/types'; @@ -66,10 +68,12 @@ interface KanbanBoardProps { teamName: string; kanbanState: KanbanState; filter: KanbanFilterState; + sort: KanbanSortState; sessions: Session[]; leadSessionId?: string; members: ResolvedTeamMember[]; onFilterChange: (filter: KanbanFilterState) => void; + onSortChange: (sort: KanbanSortState) => void; onRequestReview: (taskId: string) => void; onApprove: (taskId: string) => void; onRequestChanges: (taskId: string) => void; @@ -149,6 +153,47 @@ function sortColumnTasksByOrder(columnTasks: TeamTask[], order?: string[]): Team return ordered; } +/** Сортирует задачи по выбранному полю. */ +function sortColumnTasksByField( + columnTasks: TeamTask[], + field: KanbanSortField, + order?: string[] +): TeamTask[] { + if (field === 'manual') { + return sortColumnTasksByOrder(columnTasks, order); + } + + return [...columnTasks].sort((a, b) => { + if (field === 'updatedAt') { + const tsA = a.updatedAt + ? new Date(a.updatedAt).getTime() + : a.createdAt + ? new Date(a.createdAt).getTime() + : 0; + const tsB = b.updatedAt + ? new Date(b.updatedAt).getTime() + : b.createdAt + ? new Date(b.createdAt).getTime() + : 0; + return tsB - tsA; // desc — свежие вверху + } + if (field === 'createdAt') { + const tsA = a.createdAt ? new Date(a.createdAt).getTime() : 0; + const tsB = b.createdAt ? new Date(b.createdAt).getTime() : 0; + return tsB - tsA; // desc — новые вверху + } + if (field === 'owner') { + const ownerA = (a.owner ?? '').toLowerCase(); + const ownerB = (b.owner ?? '').toLowerCase(); + if (!ownerA && !ownerB) return 0; + if (!ownerA) return 1; // unassigned — в конец + if (!ownerB) return -1; + return ownerA.localeCompare(ownerB); + } + return 0; + }); +} + interface SortableKanbanTaskCardProps { task: TeamTask; columnId: KanbanColumnId; @@ -234,10 +279,12 @@ export const KanbanBoard = ({ teamName, kanbanState, filter, + sort, sessions, leadSessionId, members, onFilterChange, + onSortChange, onRequestReview, onApprove, onRequestChanges, @@ -277,10 +324,10 @@ export const KanbanBoard = ({ for (const column of COLUMNS) { const columnTasks = grouped.get(column.id) ?? []; const order = kanbanState.columnOrder?.[column.id]; - result.set(column.id, sortColumnTasksByOrder(columnTasks, order)); + result.set(column.id, sortColumnTasksByField(columnTasks, sort.field, order)); } return result; - }, [grouped, kanbanState.columnOrder]); + }, [grouped, kanbanState.columnOrder, sort.field]); const sensors = useSensors( useSensor(PointerSensor, { @@ -343,7 +390,7 @@ export const KanbanBoard = ({ ) ); } - if (onColumnOrderChange) { + if (onColumnOrderChange && sort.field === 'manual') { const itemIds = columnTasks.map((t) => t.id); return ( <> @@ -423,13 +470,17 @@ export const KanbanBoard = ({
{toolbarLeft != null &&
{toolbarLeft}
}
- +
+ +
+ +
{deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? ( @@ -543,7 +594,7 @@ export const KanbanBoard = ({ ); - if (onColumnOrderChange) { + if (onColumnOrderChange && sort.field === 'manual') { return ( {boardContent} diff --git a/src/renderer/components/team/kanban/KanbanSortPopover.tsx b/src/renderer/components/team/kanban/KanbanSortPopover.tsx new file mode 100644 index 00000000..f05c2491 --- /dev/null +++ b/src/renderer/components/team/kanban/KanbanSortPopover.tsx @@ -0,0 +1,140 @@ +import { Button } from '@renderer/components/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { cn } from '@renderer/lib/utils'; +import { ArrowDownUp, ArrowUpDown, Calendar, Clock, GripVertical, User } from 'lucide-react'; + +export type KanbanSortField = 'updatedAt' | 'createdAt' | 'owner' | 'manual'; + +export interface KanbanSortState { + field: KanbanSortField; +} + +const SORT_OPTIONS: { + field: KanbanSortField; + label: string; + description: string; + icon: React.ReactNode; +}[] = [ + { + field: 'updatedAt', + label: 'Last updated', + description: 'Recently updated first', + icon: , + }, + { + field: 'createdAt', + label: 'Created', + description: 'Newest first', + icon: , + }, + { + field: 'owner', + label: 'Owner', + description: 'Alphabetically by assignee', + icon: , + }, + { + field: 'manual', + label: 'Manual', + description: 'Drag-and-drop order', + icon: , + }, +]; + +interface KanbanSortPopoverProps { + sort: KanbanSortState; + onSortChange: (sort: KanbanSortState) => void; +} + +export const KanbanSortPopover = ({ + sort, + onSortChange, +}: KanbanSortPopoverProps): React.JSX.Element => { + const isNonDefault = sort.field !== 'updatedAt'; + + return ( + + + + + + + + Sort tasks + + +
+

+ Sort by +

+
+ {SORT_OPTIONS.map((option) => { + const isSelected = sort.field === option.field; + return ( + + ); + })} +
+
+ {isNonDefault && ( +
+ +
+ )} +
+
+ ); +};