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.
This commit is contained in:
iliya 2026-03-11 12:54:04 +02:00
parent 8216d25eac
commit c5c41d2a0d
19 changed files with 488 additions and 250 deletions

View file

@ -20,23 +20,39 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
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 } : {}),
})
)
);

View file

@ -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',

View file

@ -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: "<taskId>", owner: "<your-name>" }
- 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: "<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: "<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: "<taskId>", note?: "<optional 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: "<taskId>", comment: "<what to fix>" }
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: "<taskId>", text: "<your reply>", from: "<your-name>" }
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: "<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.
8. When sending a message about a specific task, include its short display label like #<displayId> 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 #<displayId> 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: "<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: "<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?: "<actual-member-name>", blockedBy?: ["1","2"], related?: ["3"] }`,
`- Create task: task_create { teamName: "${teamName}", subject: "...", description?: "...", owner?: "<actual-member-name>", createdBy?: "<your-name>", blockedBy?: ["1","2"], related?: ["3"] }`,
`- Assign/reassign owner: task_set_owner { teamName: "${teamName}", taskId: "<id>", owner: "<member-name>" }`,
`- Clear owner: task_set_owner { teamName: "${teamName}", taskId: "<id>", owner: null }`,
`- Start task (preferred over set-status): task_start { teamName: "${teamName}", taskId: "<id>" }`,
@ -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: "<id>" }`,
` 2) Attach: task_attach_comment_file { teamName: "${teamName}", taskId: "<id>", commentId: "<commentId>", filePath: "<path>", mode?: "copy|link", filename?: "<name>", mimeType?: "<type>" }`,
`- Create with deps (blocked work MUST be pending): task_create { teamName: "${teamName}", subject: "...", owner: "<member>", blockedBy: ["1","2"], related?: ["3"], startImmediately: false }`,
`- Create with deps (blocked work MUST be pending): task_create { teamName: "${teamName}", subject: "...", owner: "<member>", createdBy: "<your-name>", blockedBy: ["1","2"], related?: ["3"], startImmediately: false }`,
`- Link dependency: task_link { teamName: "${teamName}", taskId: "<id>", targetId: "<targetId>", relationship: "blocked-by" }`,
`- Link related: task_link { teamName: "${teamName}", taskId: "<id>", targetId: "<targetId>", relationship: "related" }`,
`- Unlink: task_unlink { teamName: "${teamName}", taskId: "<id>", targetId: "<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 #<id> (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');

View file

@ -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)' }}
>
<div className="relative flex flex-1 overflow-hidden">
{/* Context panel sidebar (left) */}
{isContextPanelVisible && allContextInjections.length > 0 && (
<div className="w-80 shrink-0">
<SessionContextPanel
injections={allContextInjections}
onClose={() => 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"
/>
</div>
)}
{/* Chat content */}
<div
ref={scrollContainerRef}
@ -842,7 +862,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
>
{/* Sticky Context button */}
{allContextInjections.length > 0 && (
<div className="pointer-events-none sticky top-0 z-10 flex justify-end px-4 pb-0 pt-3">
<div className="pointer-events-none sticky top-0 z-10 flex justify-start px-4 pb-0 pt-3">
<button
onClick={() => setContextPanelVisible(!isContextPanelVisible)}
onMouseEnter={() => setIsContextButtonHovered(true)}
@ -981,10 +1001,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
}}
className="absolute bottom-5 z-20 flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs shadow-lg backdrop-blur-md transition-all"
style={{
right:
isContextPanelVisible && allContextInjections.length > 0
? `calc(${CONTEXT_PANEL_WIDTH_PX}px + 1rem)`
: '1rem',
right: '1rem',
backgroundColor: 'var(--context-btn-bg)',
color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border-emphasis)',
@ -995,27 +1012,6 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
<span>Bottom</span>
</button>
)}
{/* Context panel sidebar */}
{isContextPanelVisible && allContextInjections.length > 0 && (
<div className="w-80 shrink-0">
<SessionContextPanel
injections={allContextInjections}
onClose={() => 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}
/>
</div>
)}
</div>
</div>
);

View file

@ -55,6 +55,7 @@ export const SessionContextPanel = ({
phaseInfo,
selectedPhase,
onPhaseChange,
side = 'left',
}: Readonly<SessionContextPanelProps>): React.ReactElement => {
// View mode: category sections or ranked list
const [viewMode, setViewMode] = useState<ContextViewMode>('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}` }),
}}
>
<SessionContextHeader

View file

@ -37,6 +37,8 @@ export interface SessionContextPanelProps {
selectedPhase: number | null;
/** Callback to change selected phase */
onPhaseChange: (phase: number | null) => void;
/** Which side of the content the panel is on: left → borderRight, right → borderLeft */
side?: 'left' | 'right';
}
// =============================================================================

View file

@ -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

View file

@ -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}
>

View file

@ -42,12 +42,12 @@ export const Sidebar = (): React.JSX.Element => {
const [isCollapseHovered, setIsCollapseHovered] = useState(false);
const sidebarRef = useRef<HTMLDivElement>(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 (
<div
ref={sidebarRef}
className="relative flex shrink-0 flex-col overflow-hidden border-r"
className="relative flex shrink-0 flex-col overflow-hidden border-l"
style={{
backgroundColor: 'var(--color-surface-sidebar)',
borderColor: 'var(--color-border)',
width: sidebarCollapsed ? 0 : width,
minWidth: sidebarCollapsed ? 0 : undefined,
borderRightWidth: sidebarCollapsed ? 0 : undefined,
borderLeftWidth: sidebarCollapsed ? 0 : undefined,
transition: 'width 0.22s ease-out, border-width 0.22s ease-out',
}}
>
<div
className="flex min-w-0 flex-1 flex-col overflow-hidden pr-2"
className="flex min-w-0 flex-1 flex-col overflow-hidden pl-2"
style={{
width: '100%',
minWidth: sidebarCollapsed ? 0 : width,
@ -204,7 +204,7 @@ export const Sidebar = (): React.JSX.Element => {
<button
type="button"
aria-label="Resize sidebar"
className={`absolute right-0 top-0 h-full w-1 cursor-col-resize border-0 bg-transparent p-0 transition-colors hover:bg-blue-500/50 ${
className={`absolute left-0 top-0 h-full w-1 cursor-col-resize border-0 bg-transparent p-0 transition-colors hover:bg-blue-500/50 ${
isResizing ? 'bg-blue-500/50' : ''
}`}
onMouseDown={handleResizeStart}

View file

@ -14,7 +14,7 @@ import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortabl
import { isElectronMode } from '@renderer/api';
import { useStore } from '@renderer/store';
import { formatShortcut } from '@renderer/utils/stringUtils';
import { PanelLeft, RefreshCw } from 'lucide-react';
import { RefreshCw } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { SortableTab } from './SortableTab';
@ -38,8 +38,6 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
clearTabSelection,
fetchSessionDetail,
fetchSessions,
sidebarCollapsed,
toggleSidebar,
splitPane,
togglePinSession,
pinnedSessionIds,
@ -59,8 +57,6 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
clearTabSelection: s.clearTabSelection,
fetchSessionDetail: s.fetchSessionDetail,
fetchSessions: s.fetchSessions,
sidebarCollapsed: s.sidebarCollapsed,
toggleSidebar: s.toggleSidebar,
splitPane: s.splitPane,
togglePinSession: s.togglePinSession,
pinnedSessionIds: s.pinnedSessionIds,
@ -80,7 +76,6 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
const tabIds = useMemo(() => openTabs.map((t) => t.id), [openTabs]);
// Hover states for buttons
const [expandHover, setExpandHover] = useState(false);
const [refreshHover, setRefreshHover] = useState(false);
// Context menu state
@ -253,23 +248,6 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
} as React.CSSProperties
}
>
{/* Expand sidebar button - show when collapsed (only in leftmost pane) */}
{sidebarCollapsed && isLeftmostPane && (
<button
onClick={toggleSidebar}
onMouseEnter={() => setExpandHover(true)}
onMouseLeave={() => setExpandHover(false)}
className="mr-2 shrink-0 rounded-md p-1.5 transition-colors"
style={{
color: expandHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: expandHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="Expand sidebar"
>
<PanelLeft className="size-4" />
</button>
)}
{/* Tab list with horizontal scroll, sortable DnD, and droppable area.
Capped at 75% so the drag spacer always has room to the right. */}
<div

View file

@ -8,7 +8,7 @@ import { useMemo, useState } from 'react';
import { isElectronMode } from '@renderer/api';
import { useStore } from '@renderer/store';
import { Bell, Calendar, Puzzle, Settings, Users } from 'lucide-react';
import { Bell, Puzzle, Settings, Users, PanelRight } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { MoreMenu } from './MoreMenu';
@ -19,32 +19,34 @@ export const TabBarActions = (): React.JSX.Element => {
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 => {
<Users className="size-4" />
</button>
{/* Schedules icon */}
<button
onClick={openSchedulesTab}
onMouseEnter={() => setSchedulesHover(true)}
onMouseLeave={() => setSchedulesHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: schedulesHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: schedulesHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="Schedules"
>
<Calendar className="size-4" />
</button>
{/* Extensions icon */}
<button
onClick={openExtensionsTab}
@ -161,12 +148,29 @@ export const TabBarActions = (): React.JSX.Element => {
<Settings className="size-4" />
</button>
{/* More menu (Search, Export, Analyze) */}
{/* More menu (Search, Export, Analyze, Schedules) */}
<MoreMenu
activeTab={activeTab}
activeTabSessionDetail={activeTabSessionDetail}
activeTabId={activeTabId}
/>
{/* Expand sidebar — rightmost, only when collapsed */}
{sidebarCollapsed && (
<button
onClick={toggleSidebar}
onMouseEnter={() => setExpandHover(true)}
onMouseLeave={() => setExpandHover(false)}
className="mr-1 rounded-md p-2 transition-colors"
style={{
color: expandHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: expandHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="Expand sidebar"
>
<PanelRight className="size-4" />
</button>
)}
</div>
);
};

View file

@ -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 => {
</div>
</Fragment>
))}
{/* New tab button — right after last tab */}
<button
onClick={openDashboard}
onMouseEnter={() => setNewTabHover(true)}
onMouseLeave={() => setNewTabHover(false)}
className="shrink-0 self-stretch px-2 transition-colors"
style={
{
WebkitAppRegion: 'no-drag',
color: newTabHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: newTabHover ? 'var(--color-surface-raised)' : 'transparent',
} as React.CSSProperties
}
title="New tab (Dashboard)"
>
<Plus className="size-4" />
</button>
</div>
{/* New tab button — right corner */}
<button
onClick={openDashboard}
onMouseEnter={() => setNewTabHover(true)}
onMouseLeave={() => setNewTabHover(false)}
className="mr-2 shrink-0 rounded-md p-2 transition-colors"
style={
{
WebkitAppRegion: 'no-drag',
color: newTabHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: newTabHover ? 'var(--color-surface-raised)' : 'transparent',
} as React.CSSProperties
}
title="New tab (Dashboard)"
>
<Plus className="size-4" />
</button>
{/* Action buttons — right side */}
<TabBarActions />
</div>
);
};

View file

@ -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) */}
<CommandPalette />
{/* Sidebar - Task list / Sessions (280px) */}
<Sidebar />
{/* Content column: floating actions bar + pane content */}
{/* Content area */}
<div
className="relative flex min-w-0 flex-1 flex-col overflow-hidden"
style={{ background: 'transparent' }}
>
{/* Content header with action buttons — floats over pane content */}
<div
className="absolute right-0 top-0 z-10 flex items-center justify-end pr-2"
style={{
height: '36px',
left: 0,
backgroundColor: 'rgba(20, 20, 22, 0.45)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
borderBottom: '1px solid var(--color-border)',
}}
>
<TabBarActions />
</div>
{/* Multi-pane content area — renders from top:0, behind the floating bar */}
<PaneContainer />
</div>
{/* Sidebar - Task list / Sessions (right side) */}
<Sidebar />
</div>
{/* Drag overlay - semi-transparent ghost of the dragged tab */}

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-3 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 ${task.teamDeleted ? 'opacity-50' : ''}`}
style={{ borderColor: 'var(--color-border)' }}
onClick={() => {
if (!isRenaming) {
@ -156,40 +156,42 @@ export const SidebarTaskItem = ({
}}
>
{/* Row 1: status + subject */}
<div className="flex w-full items-start gap-1.5 overflow-hidden">
<StatusIcon className={`mt-0.5 size-3 shrink-0 ${cfg.color}`} />
<div className="w-full overflow-hidden">
{isRenaming ? (
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
<div className="flex items-start gap-1.5">
<StatusIcon className={`mt-0.5 size-3 shrink-0 ${cfg.color}`} />
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const trimmed = editValue.trim();
if (trimmed && trimmed !== task.subject) {
onRenameComplete?.(task.teamName, task.id, trimmed);
} else {
onRenameCancel?.();
}
} else if (e.key === 'Escape') {
e.preventDefault();
onRenameCancel?.();
}
}}
onBlur={() => {
const trimmed = editValue.trim();
if (trimmed && trimmed !== task.subject) {
onRenameComplete?.(task.teamName, task.id, trimmed);
} else {
onRenameCancel?.();
}
} else if (e.key === 'Escape') {
e.preventDefault();
onRenameCancel?.();
}
}}
onBlur={() => {
const trimmed = editValue.trim();
if (trimmed && trimmed !== task.subject) {
onRenameComplete?.(task.teamName, task.id, trimmed);
} else {
onRenameCancel?.();
}
}}
className="min-w-0 flex-1 border-none bg-transparent p-0 text-[13px] font-medium leading-tight focus:outline-none"
style={{ color: 'var(--color-text-muted)' }}
onClick={(e) => e.stopPropagation()}
/>
}}
className="min-w-0 flex-1 border-none bg-transparent p-0 text-[13px] font-medium leading-tight focus:outline-none"
style={{ color: 'var(--color-text-muted)' }}
onClick={(e) => e.stopPropagation()}
/>
</div>
) : (
<Tooltip>
<TooltipTrigger asChild>
@ -197,7 +199,21 @@ export const SidebarTaskItem = ({
className="line-clamp-2 text-[13px] font-medium leading-tight"
style={{ color: 'var(--color-text-muted)' }}
>
<StatusIcon className={`mr-1.5 inline-block size-3 align-[-1px] ${cfg.color}`} />
{displaySubject}
{task.reviewState === 'needsFix' && (
<span
className={`ml-1.5 inline-block rounded-full px-1.5 py-0.5 align-middle text-[10px] font-medium leading-none ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
>
{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}>
@ -205,19 +221,6 @@ export const SidebarTaskItem = ({
</TooltipContent>
</Tooltip>
)}
{task.reviewState === 'needsFix' && !isRenaming ? (
<span
className={`shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
>
{REVIEW_STATE_DISPLAY.needsFix.label}
</span>
) : null}
{unreadCount > 0 && !isRenaming && (
<span
className="size-1.5 shrink-0 rounded-full bg-blue-400"
title={`${unreadCount} unread`}
/>
)}
</div>
{/* Row 2: project + owner (when no team row) + date */}

View file

@ -89,6 +89,7 @@ import { TeamProvisioningBanner } from './TeamProvisioningBanner';
import { TeamSessionsSection } from './TeamSessionsSection';
import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
import type { KanbanSortState } from './kanban/KanbanSortPopover';
import type { MessagesFilterState } from './messages/MessagesFilterPopover';
import type { ContextInjection } from '@renderer/types/contextInjection';
import type { Session } from '@renderer/types/data';
@ -205,6 +206,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
selectedOwners: new Set(),
columns: new Set(),
});
const [kanbanSort, setKanbanSort] = useState<KanbanSortState>({ field: 'updatedAt' });
const {
data,
@ -513,7 +515,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
const id = window.setInterval(() => {
void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true });
}, 30_000);
}, 10_000);
return () => window.clearInterval(id);
}, [isThisTabActive, tabId, projectId, leadSessionId, data?.isAlive, fetchSessionDetail]);
@ -956,16 +958,65 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
return (
<>
<div className="flex size-full overflow-hidden">
{/* Context panel sidebar (left) */}
{isContextPanelVisible && leadSessionId && (
<div className="w-80 shrink-0">
{leadSessionLoaded ? (
<SessionContextPanel
injections={allContextInjections}
onClose={() => setContextPanelVisible(false)}
projectRoot={leadSessionDetail?.session?.projectPath ?? data.config.projectPath}
totalSessionTokens={lastAiGroupTotalTokens}
sessionMetrics={leadSessionDetail?.metrics}
subagentCostUsd={leadSubagentCostUsd}
phaseInfo={leadSessionPhaseInfo ?? undefined}
selectedPhase={selectedContextPhase}
onPhaseChange={setSelectedContextPhase}
side="left"
/>
) : (
<div
className="flex h-full flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)]"
style={{ backgroundColor: 'var(--color-surface)' }}
>
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2">
<div className="min-w-0">
<p className="text-sm font-medium text-[var(--color-text)]">Visible Context</p>
<p className="text-[10px] text-[var(--color-text-muted)]">
{leadSessionLoading ? 'Loading…' : 'No session loaded'}
</p>
</div>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={() => setContextPanelVisible(false)}
aria-label="Close panel"
>
×
</button>
</div>
<div className="flex flex-1 items-center justify-center p-4">
<p className="text-xs text-[var(--color-text-muted)]">
{leadSessionLoading
? 'Loading context…'
: 'Open the team lead session to view context.'}
</p>
</div>
</div>
)}
</div>
)}
<div
ref={contentRef}
className="relative size-full flex-1 overflow-auto p-4"
data-team-name={teamName}
>
{/* Context button pinned to bottom-right of viewport */}
{/* Context button pinned to bottom-left of viewport */}
{leadSessionId && (
<div
className="pointer-events-none fixed bottom-4 z-20"
style={{ right: isContextPanelVisible ? 'calc(20rem + 1rem)' : '1rem' }}
style={{ left: isContextPanelVisible ? 'calc(20rem + 1rem)' : '1rem' }}
>
<button
onClick={() => {
@ -1303,10 +1354,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
teamName={teamName}
kanbanState={data.kanbanState}
filter={kanbanFilter}
sort={kanbanSort}
sessions={teamSessions}
leadSessionId={data.config.leadSessionId}
members={activeMembers}
onFilterChange={setKanbanFilter}
onSortChange={setKanbanSort}
toolbarLeft={
<div className="relative max-w-[240px]">
<Search
@ -2026,54 +2079,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
onEditorAction={handleEditorAction}
/>
</div>
{/* Context panel sidebar */}
{isContextPanelVisible && leadSessionId && (
<div className="w-80 shrink-0">
{leadSessionLoaded ? (
<SessionContextPanel
injections={allContextInjections}
onClose={() => setContextPanelVisible(false)}
projectRoot={leadSessionDetail?.session?.projectPath ?? data.config.projectPath}
totalSessionTokens={lastAiGroupTotalTokens}
sessionMetrics={leadSessionDetail?.metrics}
subagentCostUsd={leadSubagentCostUsd}
phaseInfo={leadSessionPhaseInfo ?? undefined}
selectedPhase={selectedContextPhase}
onPhaseChange={setSelectedContextPhase}
/>
) : (
<div
className="flex h-full flex-col border-l border-[var(--color-border)] bg-[var(--color-surface)]"
style={{ backgroundColor: 'var(--color-surface)' }}
>
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2">
<div className="min-w-0">
<p className="text-sm font-medium text-[var(--color-text)]">Visible Context</p>
<p className="text-[10px] text-[var(--color-text-muted)]">
{leadSessionLoading ? 'Loading…' : 'No session loaded'}
</p>
</div>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={() => setContextPanelVisible(false)}
aria-label="Close panel"
>
×
</button>
</div>
<div className="flex flex-1 items-center justify-center p-4">
<p className="text-xs text-[var(--color-text-muted)]">
{leadSessionLoading
? 'Loading context…'
: 'Open the team lead session to view context.'}
</p>
</div>
</div>
)}
</div>
)}
</div>
{editorOpen && data.config.projectPath && (

View file

@ -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

View file

@ -47,7 +47,7 @@ export const WorkflowTimeline = ({ events, memberColorMap }: WorkflowTimelinePro
{time}
</span>
<EventContent event={event} memberColorMap={memberColorMap} />
{event.actor ? (
{shouldShowTrailingActor(event) && event.actor ? (
<span className="ml-auto shrink-0">
<MemberBadge
name={event.actor}
@ -86,6 +86,17 @@ const EventContent = ({
<Plus size={10} />
Created as
<StatusBadge status={event.status} />
{event.actor ? (
<>
<span className="text-[var(--color-text-muted)]">by</span>
<MemberBadge
name={event.actor}
color={memberColorMap?.get(event.actor)}
size="sm"
hideAvatar
/>
</>
) : null}
</span>
);
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':

View file

@ -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 = ({
<div className={cn('mb-2 flex items-center gap-2', toolbarLeft == null && 'justify-end')}>
{toolbarLeft != null && <div className="min-w-0 flex-1">{toolbarLeft}</div>}
<div className="flex shrink-0 items-center gap-2">
<KanbanFilterPopover
filter={filter}
sessions={sessions}
leadSessionId={leadSessionId}
members={members}
onFilterChange={onFilterChange}
/>
<div className="inline-flex items-center rounded-md border border-[var(--color-border)]">
<KanbanFilterPopover
filter={filter}
sessions={sessions}
leadSessionId={leadSessionId}
members={members}
onFilterChange={onFilterChange}
/>
<div className="h-4 w-px bg-[var(--color-border)]" />
<KanbanSortPopover sort={sort} onSortChange={onSortChange} />
</div>
{deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? (
<Tooltip>
<TooltipTrigger asChild>
@ -543,7 +594,7 @@ export const KanbanBoard = ({
</>
);
if (onColumnOrderChange) {
if (onColumnOrderChange && sort.field === 'manual') {
return (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
{boardContent}

View file

@ -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: <Clock size={14} />,
},
{
field: 'createdAt',
label: 'Created',
description: 'Newest first',
icon: <Calendar size={14} />,
},
{
field: 'owner',
label: 'Owner',
description: 'Alphabetically by assignee',
icon: <User size={14} />,
},
{
field: 'manual',
label: 'Manual',
description: 'Drag-and-drop order',
icon: <GripVertical size={14} />,
},
];
interface KanbanSortPopoverProps {
sort: KanbanSortState;
onSortChange: (sort: KanbanSortState) => void;
}
export const KanbanSortPopover = ({
sort,
onSortChange,
}: KanbanSortPopoverProps): React.JSX.Element => {
const isNonDefault = sort.field !== 'updatedAt';
return (
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
aria-label="Sort tasks"
>
<ArrowUpDown size={14} />
{isNonDefault && (
<span className="absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-medium text-white">
1
</span>
)}
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Sort tasks</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-56 p-0">
<div className="p-3">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
Sort by
</p>
<div className="space-y-0.5">
{SORT_OPTIONS.map((option) => {
const isSelected = sort.field === option.field;
return (
<button
key={option.field}
type="button"
className={cn(
'flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-xs transition-colors',
isSelected
? 'bg-blue-500/15 text-blue-300'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
)}
onClick={() => onSortChange({ field: option.field })}
>
<span
className={cn(
'shrink-0',
isSelected ? 'text-blue-400' : 'text-[var(--color-text-muted)]'
)}
>
{option.icon}
</span>
<div className="min-w-0">
<div className="font-medium">{option.label}</div>
<div
className={cn(
'text-[10px]',
isSelected ? 'text-blue-300/70' : 'text-[var(--color-text-muted)]'
)}
>
{option.description}
</div>
</div>
{isSelected && (
<ArrowDownUp size={12} className="ml-auto shrink-0 text-blue-400" />
)}
</button>
);
})}
</div>
</div>
{isNonDefault && (
<div className="flex justify-end border-t border-[var(--color-border)] p-2">
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={() => onSortChange({ field: 'updatedAt' })}
>
Reset
</Button>
</div>
)}
</PopoverContent>
</Popover>
);
};