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), subject: z.string().min(1),
description: z.string().optional(), description: z.string().optional(),
owner: z.string().optional(), owner: z.string().optional(),
createdBy: z.string().optional(),
from: z.string().optional(),
blockedBy: z.array(z.string().min(1)).optional(), blockedBy: z.array(z.string().min(1)).optional(),
related: z.array(z.string().min(1)).optional(), related: z.array(z.string().min(1)).optional(),
prompt: z.string().optional(), prompt: z.string().optional(),
startImmediately: z.boolean().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); const controller = getController(teamName, claudeDir);
return await Promise.resolve( return await Promise.resolve(
jsonTextContent( jsonTextContent(
controller.tasks.createTask({ controller.tasks.createTask({
subject, subject,
...(description ? { description } : {}), ...(description ? { description } : {}),
...(owner ? { owner } : {}), ...(owner ? { owner } : {}),
...(blockedBy?.length ? { 'blocked-by': blockedBy.join(',') } : {}), ...(createdBy ? { createdBy } : {}),
...(related?.length ? { related: related.join(',') } : {}), ...(!createdBy && from ? { from } : {}),
...(prompt ? { prompt } : {}), ...(blockedBy?.length ? { 'blocked-by': blockedBy.join(',') } : {}),
...(startImmediately !== undefined ? { startImmediately } : {}), ...(related?.length ? { related: related.join(',') } : {}),
...(prompt ? { prompt } : {}),
...(startImmediately !== undefined ? { startImmediately } : {}),
}) })
) )
); );

View file

@ -109,9 +109,11 @@ describe('agent-teams-mcp tools', () => {
teamName, teamName,
subject: 'Review MCP adapter', subject: 'Review MCP adapter',
owner: 'alice', owner: 'alice',
createdBy: 'ui-fixer',
}) })
); );
expect(createdTask.status).toBe('pending'); expect(createdTask.status).toBe('pending');
expect(createdTask.historyEvents?.[0]?.actor).toBe('ui-fixer');
const listedTasks = parseJsonToolResult( const listedTasks = parseJsonToolResult(
await getTool('task_list').execute({ await getTool('task_list').execute({
@ -558,6 +560,15 @@ describe('agent-teams-mcp tools', () => {
}).success }).success
).toBe(false); ).toBe(false);
expect(
getTool('task_create').parameters?.safeParse({
teamName: 'demo',
claudeDir: '/tmp/demo',
subject: 'Created by schema',
createdBy: 'ui-fixer',
}).success
).toBe(true);
expect( expect(
getTool('process_register').parameters?.safeParse({ getTool('process_register').parameters?.safeParse({
teamName: 'demo', 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. - 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. - 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. - 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>" } { teamName: "${teamName}", taskId: "<taskId>" }
- Start the task ONLY when you are actually beginning work on it. - 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. - 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>" } { 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 } { 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>" } { 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. - 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>" } { 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>" } { 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. 9. 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. 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): 11. 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
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: 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: 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" }
@ -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: 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" }
e) Do NOT set clarification to "user" yourself only the team lead escalates to the user. 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 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.
13. TASK QUEUE DISCIPLINE: 14. TASK QUEUE DISCIPLINE:
- Use task_briefing as a compact queue view of your assigned tasks. - 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. - 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. - 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. - 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. - 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.`); 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.`, `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:`, `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>" }`, `- Assign/reassign owner: task_set_owner { teamName: "${teamName}", taskId: "<id>", owner: "<member-name>" }`,
`- Clear owner: task_set_owner { teamName: "${teamName}", taskId: "<id>", owner: null }`, `- Clear owner: task_set_owner { teamName: "${teamName}", taskId: "<id>", owner: null }`,
`- Start task (preferred over set-status): task_start { teamName: "${teamName}", taskId: "<id>" }`, `- 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:`, `- Attach file to a specific comment:`,
` 1) Find commentId: task_get { teamName: "${teamName}", taskId: "<id>" }`, ` 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>" }`, ` 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 dependency: task_link { teamName: "${teamName}", taskId: "<id>", targetId: "<targetId>", relationship: "blocked-by" }`,
`- Link related: task_link { teamName: "${teamName}", taskId: "<id>", targetId: "<targetId>", relationship: "related" }`, `- Link related: task_link { teamName: "${teamName}", taskId: "<id>", targetId: "<targetId>", relationship: "related" }`,
`- Unlink: task_unlink { teamName: "${teamName}", taskId: "<id>", targetId: "<targetId>", relationship: "blocked-by" }`, `- 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:`, `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.`, `- 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.`, `- 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):`, `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.`, `- 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: When you receive that follow-up message:
- Execute tasks sequentially and keep the board + user updated: - Execute tasks sequentially and keep the board + user updated:
- Identify the next READY task (pending, not blocked by incomplete dependencies). - 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. - 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). - 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). - Immediately SendMessage "user" that you started task #<id> (what you're doing + next step).
@ -893,7 +902,9 @@ ${actionModeProtocol}
Then: 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. - 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. - 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.`; - If you have no tasks, wait for new assignments.`;
}) })
.join('\n\n'); .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. */ /** Pixels from bottom considered "near bottom" for scroll-button visibility and auto-scroll. */
const SCROLL_THRESHOLD = 300; 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 { import {
computeRemainingContext, computeRemainingContext,
@ -833,6 +831,28 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
style={{ backgroundColor: 'var(--color-surface)' }} style={{ backgroundColor: 'var(--color-surface)' }}
> >
<div className="relative flex flex-1 overflow-hidden"> <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 */} {/* Chat content */}
<div <div
ref={scrollContainerRef} ref={scrollContainerRef}
@ -842,7 +862,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
> >
{/* Sticky Context button */} {/* Sticky Context button */}
{allContextInjections.length > 0 && ( {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 <button
onClick={() => setContextPanelVisible(!isContextPanelVisible)} onClick={() => setContextPanelVisible(!isContextPanelVisible)}
onMouseEnter={() => setIsContextButtonHovered(true)} 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" 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={{ style={{
right: right: '1rem',
isContextPanelVisible && allContextInjections.length > 0
? `calc(${CONTEXT_PANEL_WIDTH_PX}px + 1rem)`
: '1rem',
backgroundColor: 'var(--context-btn-bg)', backgroundColor: 'var(--context-btn-bg)',
color: 'var(--color-text-secondary)', color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border-emphasis)', border: '1px solid var(--color-border-emphasis)',
@ -995,27 +1012,6 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
<span>Bottom</span> <span>Bottom</span>
</button> </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>
</div> </div>
); );

View file

@ -55,6 +55,7 @@ export const SessionContextPanel = ({
phaseInfo, phaseInfo,
selectedPhase, selectedPhase,
onPhaseChange, onPhaseChange,
side = 'left',
}: Readonly<SessionContextPanelProps>): React.ReactElement => { }: Readonly<SessionContextPanelProps>): React.ReactElement => {
// View mode: category sections or ranked list // View mode: category sections or ranked list
const [viewMode, setViewMode] = useState<ContextViewMode>('category'); const [viewMode, setViewMode] = useState<ContextViewMode>('category');
@ -184,7 +185,9 @@ export const SessionContextPanel = ({
className="flex h-full flex-col" className="flex h-full flex-col"
style={{ style={{
backgroundColor: COLOR_SURFACE, backgroundColor: COLOR_SURFACE,
borderLeft: `1px solid ${COLOR_BORDER}`, ...(side === 'left'
? { borderRight: `1px solid ${COLOR_BORDER}` }
: { borderLeft: `1px solid ${COLOR_BORDER}` }),
}} }}
> >
<SessionContextHeader <SessionContextHeader

View file

@ -37,6 +37,8 @@ export interface SessionContextPanelProps {
selectedPhase: number | null; selectedPhase: number | null;
/** Callback to change selected phase */ /** Callback to change selected phase */
onPhaseChange: (phase: number | null) => void; 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 { useStore } from '@renderer/store';
import { triggerDownload } from '@renderer/utils/sessionExporter'; import { triggerDownload } from '@renderer/utils/sessionExporter';
import { formatShortcut } from '@renderer/utils/stringUtils'; 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 { SessionDetail } from '@renderer/types/data';
import type { Tab } from '@renderer/types/tabs'; import type { Tab } from '@renderer/types/tabs';
@ -42,6 +42,7 @@ export const MoreMenu = ({
const openCommandPalette = useStore((s) => s.openCommandPalette); const openCommandPalette = useStore((s) => s.openCommandPalette);
const openSessionReport = useStore((s) => s.openSessionReport); const openSessionReport = useStore((s) => s.openSessionReport);
const openSchedulesTab = useStore((s) => s.openSchedulesTab);
// Close on outside click // Close on outside click
useEffect(() => { useEffect(() => {
@ -95,6 +96,15 @@ export const MoreMenu = ({
setIsOpen(false); setIsOpen(false);
}, },
}, },
{
id: 'schedules',
label: 'Schedules',
icon: Calendar,
onClick: () => {
openSchedulesTab();
setIsOpen(false);
},
},
]; ];
const sessionItems: MenuItem[] = isSessionWithData 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" className="relative flex min-w-0 flex-col"
style={{ style={{
width: `${pane.widthFraction * 100}%`, width: `${pane.widthFraction * 100}%`,
paddingTop: '36px',
}} }}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
> >

View file

@ -42,12 +42,12 @@ export const Sidebar = (): React.JSX.Element => {
const [isCollapseHovered, setIsCollapseHovered] = useState(false); const [isCollapseHovered, setIsCollapseHovered] = useState(false);
const sidebarRef = useRef<HTMLDivElement>(null); const sidebarRef = useRef<HTMLDivElement>(null);
// Handle mouse move during resize // Handle mouse move during resize (right sidebar: width = viewport - clientX)
const handleMouseMove = useCallback( const handleMouseMove = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
if (!isResizing) return; if (!isResizing) return;
const newWidth = e.clientX; const newWidth = window.innerWidth - e.clientX;
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) { if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
setWidth(newWidth); setWidth(newWidth);
} }
@ -85,18 +85,18 @@ export const Sidebar = (): React.JSX.Element => {
return ( return (
<div <div
ref={sidebarRef} 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={{ style={{
backgroundColor: 'var(--color-surface-sidebar)', backgroundColor: 'var(--color-surface-sidebar)',
borderColor: 'var(--color-border)', borderColor: 'var(--color-border)',
width: sidebarCollapsed ? 0 : width, width: sidebarCollapsed ? 0 : width,
minWidth: sidebarCollapsed ? 0 : undefined, minWidth: sidebarCollapsed ? 0 : undefined,
borderRightWidth: sidebarCollapsed ? 0 : undefined, borderLeftWidth: sidebarCollapsed ? 0 : undefined,
transition: 'width 0.22s ease-out, border-width 0.22s ease-out', transition: 'width 0.22s ease-out, border-width 0.22s ease-out',
}} }}
> >
<div <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={{ style={{
width: '100%', width: '100%',
minWidth: sidebarCollapsed ? 0 : width, minWidth: sidebarCollapsed ? 0 : width,
@ -204,7 +204,7 @@ export const Sidebar = (): React.JSX.Element => {
<button <button
type="button" type="button"
aria-label="Resize sidebar" 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' : '' isResizing ? 'bg-blue-500/50' : ''
}`} }`}
onMouseDown={handleResizeStart} onMouseDown={handleResizeStart}

View file

@ -14,7 +14,7 @@ import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortabl
import { isElectronMode } from '@renderer/api'; import { isElectronMode } from '@renderer/api';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { formatShortcut } from '@renderer/utils/stringUtils'; import { formatShortcut } from '@renderer/utils/stringUtils';
import { PanelLeft, RefreshCw } from 'lucide-react'; import { RefreshCw } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { SortableTab } from './SortableTab'; import { SortableTab } from './SortableTab';
@ -38,8 +38,6 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
clearTabSelection, clearTabSelection,
fetchSessionDetail, fetchSessionDetail,
fetchSessions, fetchSessions,
sidebarCollapsed,
toggleSidebar,
splitPane, splitPane,
togglePinSession, togglePinSession,
pinnedSessionIds, pinnedSessionIds,
@ -59,8 +57,6 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
clearTabSelection: s.clearTabSelection, clearTabSelection: s.clearTabSelection,
fetchSessionDetail: s.fetchSessionDetail, fetchSessionDetail: s.fetchSessionDetail,
fetchSessions: s.fetchSessions, fetchSessions: s.fetchSessions,
sidebarCollapsed: s.sidebarCollapsed,
toggleSidebar: s.toggleSidebar,
splitPane: s.splitPane, splitPane: s.splitPane,
togglePinSession: s.togglePinSession, togglePinSession: s.togglePinSession,
pinnedSessionIds: s.pinnedSessionIds, pinnedSessionIds: s.pinnedSessionIds,
@ -80,7 +76,6 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
const tabIds = useMemo(() => openTabs.map((t) => t.id), [openTabs]); const tabIds = useMemo(() => openTabs.map((t) => t.id), [openTabs]);
// Hover states for buttons // Hover states for buttons
const [expandHover, setExpandHover] = useState(false);
const [refreshHover, setRefreshHover] = useState(false); const [refreshHover, setRefreshHover] = useState(false);
// Context menu state // Context menu state
@ -253,23 +248,6 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
} as React.CSSProperties } 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. {/* Tab list with horizontal scroll, sortable DnD, and droppable area.
Capped at 75% so the drag spacer always has room to the right. */} Capped at 75% so the drag spacer always has room to the right. */}
<div <div

View file

@ -8,7 +8,7 @@ import { useMemo, useState } from 'react';
import { isElectronMode } from '@renderer/api'; import { isElectronMode } from '@renderer/api';
import { useStore } from '@renderer/store'; 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 { useShallow } from 'zustand/react/shallow';
import { MoreMenu } from './MoreMenu'; import { MoreMenu } from './MoreMenu';
@ -19,32 +19,34 @@ export const TabBarActions = (): React.JSX.Element => {
openNotificationsTab, openNotificationsTab,
openTeamsTab, openTeamsTab,
openExtensionsTab, openExtensionsTab,
openSchedulesTab,
openSettingsTab, openSettingsTab,
activeTabId, activeTabId,
openTabs, openTabs,
tabSessionData, tabSessionData,
sidebarCollapsed,
toggleSidebar,
} = useStore( } = useStore(
useShallow((s) => ({ useShallow((s) => ({
unreadCount: s.unreadCount, unreadCount: s.unreadCount,
openNotificationsTab: s.openNotificationsTab, openNotificationsTab: s.openNotificationsTab,
openTeamsTab: s.openTeamsTab, openTeamsTab: s.openTeamsTab,
openExtensionsTab: s.openExtensionsTab, openExtensionsTab: s.openExtensionsTab,
openSchedulesTab: s.openSchedulesTab,
openSettingsTab: s.openSettingsTab, openSettingsTab: s.openSettingsTab,
activeTabId: s.activeTabId, activeTabId: s.activeTabId,
openTabs: s.openTabs, openTabs: s.openTabs,
tabSessionData: s.tabSessionData, tabSessionData: s.tabSessionData,
sidebarCollapsed: s.sidebarCollapsed,
toggleSidebar: s.toggleSidebar,
})) }))
); );
// Hover states for buttons // Hover states for buttons
const [notificationsHover, setNotificationsHover] = useState(false); const [notificationsHover, setNotificationsHover] = useState(false);
const [teamsHover, setTeamsHover] = useState(false); const [teamsHover, setTeamsHover] = useState(false);
const [schedulesHover, setSchedulesHover] = useState(false);
const [extensionsHover, setExtensionsHover] = useState(false); const [extensionsHover, setExtensionsHover] = useState(false);
const [githubHover, setGithubHover] = useState(false); const [githubHover, setGithubHover] = useState(false);
const [settingsHover, setSettingsHover] = useState(false); const [settingsHover, setSettingsHover] = useState(false);
const [expandHover, setExpandHover] = useState(false);
// Derive active tab and session detail for MoreMenu // Derive active tab and session detail for MoreMenu
const activeTab = useMemo( const activeTab = useMemo(
@ -95,21 +97,6 @@ export const TabBarActions = (): React.JSX.Element => {
<Users className="size-4" /> <Users className="size-4" />
</button> </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 */} {/* Extensions icon */}
<button <button
onClick={openExtensionsTab} onClick={openExtensionsTab}
@ -161,12 +148,29 @@ export const TabBarActions = (): React.JSX.Element => {
<Settings className="size-4" /> <Settings className="size-4" />
</button> </button>
{/* More menu (Search, Export, Analyze) */} {/* More menu (Search, Export, Analyze, Schedules) */}
<MoreMenu <MoreMenu
activeTab={activeTab} activeTab={activeTab}
activeTabSessionDetail={activeTabSessionDetail} activeTabSessionDetail={activeTabSessionDetail}
activeTabId={activeTabId} 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> </div>
); );
}; };

View file

@ -13,6 +13,7 @@ import { Plus } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { TabBar } from './TabBar'; import { TabBar } from './TabBar';
import { TabBarActions } from './TabBarActions';
export const TabBarRow = (): React.JSX.Element => { export const TabBarRow = (): React.JSX.Element => {
const { panes, focusedPaneId, openDashboard } = useStore( const { panes, focusedPaneId, openDashboard } = useStore(
@ -67,25 +68,28 @@ export const TabBarRow = (): React.JSX.Element => {
</div> </div>
</Fragment> </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> </div>
{/* New tab button — right corner */} {/* Action buttons — right side */}
<button <TabBarActions />
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>
</div> </div>
); );
}; };

View file

@ -37,7 +37,6 @@ import { CustomTitleBar } from './CustomTitleBar';
import { PaneContainer } from './PaneContainer'; import { PaneContainer } from './PaneContainer';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { DragOverlayTab } from './SortableTab'; import { DragOverlayTab } from './SortableTab';
import { TabBarActions } from './TabBarActions';
import { TabBarRow } from './TabBarRow'; import { TabBarRow } from './TabBarRow';
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'; import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
@ -165,32 +164,16 @@ export const TabbedLayout = (): React.JSX.Element => {
{/* Command Palette (Cmd+K) */} {/* Command Palette (Cmd+K) */}
<CommandPalette /> <CommandPalette />
{/* Sidebar - Task list / Sessions (280px) */} {/* Content area */}
<Sidebar />
{/* Content column: floating actions bar + pane content */}
<div <div
className="relative flex min-w-0 flex-1 flex-col overflow-hidden" className="relative flex min-w-0 flex-1 flex-col overflow-hidden"
style={{ background: 'transparent' }} 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 /> <PaneContainer />
</div> </div>
{/* Sidebar - Task list / Sessions (right side) */}
<Sidebar />
</div> </div>
{/* Drag overlay - semi-transparent ghost of the dragged tab */} {/* Drag overlay - semi-transparent ghost of the dragged tab */}

View file

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

View file

@ -89,6 +89,7 @@ import { TeamProvisioningBanner } from './TeamProvisioningBanner';
import { TeamSessionsSection } from './TeamSessionsSection'; import { TeamSessionsSection } from './TeamSessionsSection';
import type { KanbanFilterState } from './kanban/KanbanFilterPopover'; import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
import type { KanbanSortState } from './kanban/KanbanSortPopover';
import type { MessagesFilterState } from './messages/MessagesFilterPopover'; import type { MessagesFilterState } from './messages/MessagesFilterPopover';
import type { ContextInjection } from '@renderer/types/contextInjection'; import type { ContextInjection } from '@renderer/types/contextInjection';
import type { Session } from '@renderer/types/data'; import type { Session } from '@renderer/types/data';
@ -205,6 +206,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
selectedOwners: new Set(), selectedOwners: new Set(),
columns: new Set(), columns: new Set(),
}); });
const [kanbanSort, setKanbanSort] = useState<KanbanSortState>({ field: 'updatedAt' });
const { const {
data, data,
@ -513,7 +515,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
const id = window.setInterval(() => { const id = window.setInterval(() => {
void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true }); void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true });
}, 30_000); }, 10_000);
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, [isThisTabActive, tabId, projectId, leadSessionId, data?.isAlive, fetchSessionDetail]); }, [isThisTabActive, tabId, projectId, leadSessionId, data?.isAlive, fetchSessionDetail]);
@ -956,16 +958,65 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
return ( return (
<> <>
<div className="flex size-full overflow-hidden"> <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 <div
ref={contentRef} ref={contentRef}
className="relative size-full flex-1 overflow-auto p-4" className="relative size-full flex-1 overflow-auto p-4"
data-team-name={teamName} data-team-name={teamName}
> >
{/* Context button pinned to bottom-right of viewport */} {/* Context button pinned to bottom-left of viewport */}
{leadSessionId && ( {leadSessionId && (
<div <div
className="pointer-events-none fixed bottom-4 z-20" className="pointer-events-none fixed bottom-4 z-20"
style={{ right: isContextPanelVisible ? 'calc(20rem + 1rem)' : '1rem' }} style={{ left: isContextPanelVisible ? 'calc(20rem + 1rem)' : '1rem' }}
> >
<button <button
onClick={() => { onClick={() => {
@ -1303,10 +1354,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
teamName={teamName} teamName={teamName}
kanbanState={data.kanbanState} kanbanState={data.kanbanState}
filter={kanbanFilter} filter={kanbanFilter}
sort={kanbanSort}
sessions={teamSessions} sessions={teamSessions}
leadSessionId={data.config.leadSessionId} leadSessionId={data.config.leadSessionId}
members={activeMembers} members={activeMembers}
onFilterChange={setKanbanFilter} onFilterChange={setKanbanFilter}
onSortChange={setKanbanSort}
toolbarLeft={ toolbarLeft={
<div className="relative max-w-[240px]"> <div className="relative max-w-[240px]">
<Search <Search
@ -2026,54 +2079,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
onEditorAction={handleEditorAction} onEditorAction={handleEditorAction}
/> />
</div> </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> </div>
{editorOpen && data.config.projectPath && ( {editorOpen && data.config.projectPath && (

View file

@ -398,8 +398,15 @@ export const ActivityItem = ({
return result; return result;
}, [strippedText, memberColorMap, teamNames, systemLabel]); }, [strippedText, memberColorMap, teamNames, systemLabel]);
const rawSummary = const rawSummary = useMemo(() => {
message.summary || (structured ? getStructuredMessageSummary(structured) : '') || ''; 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]); const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]);
// Noise messages: minimal inline row // Noise messages: minimal inline row

View file

@ -47,7 +47,7 @@ export const WorkflowTimeline = ({ events, memberColorMap }: WorkflowTimelinePro
{time} {time}
</span> </span>
<EventContent event={event} memberColorMap={memberColorMap} /> <EventContent event={event} memberColorMap={memberColorMap} />
{event.actor ? ( {shouldShowTrailingActor(event) && event.actor ? (
<span className="ml-auto shrink-0"> <span className="ml-auto shrink-0">
<MemberBadge <MemberBadge
name={event.actor} name={event.actor}
@ -86,6 +86,17 @@ const EventContent = ({
<Plus size={10} /> <Plus size={10} />
Created as Created as
<StatusBadge status={event.status} /> <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> </span>
); );
case 'status_changed': 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 { function dotColorForStatus(status: TeamTaskStatus): string {
switch (status) { switch (status) {
case 'pending': case 'pending':

View file

@ -23,9 +23,11 @@ import {
import { KanbanColumn } from './KanbanColumn'; import { KanbanColumn } from './KanbanColumn';
import { KanbanFilterPopover } from './KanbanFilterPopover'; import { KanbanFilterPopover } from './KanbanFilterPopover';
import { KanbanSortPopover } from './KanbanSortPopover';
import { KanbanTaskCard } from './KanbanTaskCard'; import { KanbanTaskCard } from './KanbanTaskCard';
import type { KanbanFilterState } from './KanbanFilterPopover'; import type { KanbanFilterState } from './KanbanFilterPopover';
import type { KanbanSortField, KanbanSortState } from './KanbanSortPopover';
import type { DragEndEvent } from '@dnd-kit/core'; import type { DragEndEvent } from '@dnd-kit/core';
import type { Session } from '@renderer/types/data'; import type { Session } from '@renderer/types/data';
import type { KanbanColumnId, KanbanState, ResolvedTeamMember, TeamTask } from '@shared/types'; import type { KanbanColumnId, KanbanState, ResolvedTeamMember, TeamTask } from '@shared/types';
@ -66,10 +68,12 @@ interface KanbanBoardProps {
teamName: string; teamName: string;
kanbanState: KanbanState; kanbanState: KanbanState;
filter: KanbanFilterState; filter: KanbanFilterState;
sort: KanbanSortState;
sessions: Session[]; sessions: Session[];
leadSessionId?: string; leadSessionId?: string;
members: ResolvedTeamMember[]; members: ResolvedTeamMember[];
onFilterChange: (filter: KanbanFilterState) => void; onFilterChange: (filter: KanbanFilterState) => void;
onSortChange: (sort: KanbanSortState) => void;
onRequestReview: (taskId: string) => void; onRequestReview: (taskId: string) => void;
onApprove: (taskId: string) => void; onApprove: (taskId: string) => void;
onRequestChanges: (taskId: string) => void; onRequestChanges: (taskId: string) => void;
@ -149,6 +153,47 @@ function sortColumnTasksByOrder(columnTasks: TeamTask[], order?: string[]): Team
return ordered; 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 { interface SortableKanbanTaskCardProps {
task: TeamTask; task: TeamTask;
columnId: KanbanColumnId; columnId: KanbanColumnId;
@ -234,10 +279,12 @@ export const KanbanBoard = ({
teamName, teamName,
kanbanState, kanbanState,
filter, filter,
sort,
sessions, sessions,
leadSessionId, leadSessionId,
members, members,
onFilterChange, onFilterChange,
onSortChange,
onRequestReview, onRequestReview,
onApprove, onApprove,
onRequestChanges, onRequestChanges,
@ -277,10 +324,10 @@ export const KanbanBoard = ({
for (const column of COLUMNS) { for (const column of COLUMNS) {
const columnTasks = grouped.get(column.id) ?? []; const columnTasks = grouped.get(column.id) ?? [];
const order = kanbanState.columnOrder?.[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; return result;
}, [grouped, kanbanState.columnOrder]); }, [grouped, kanbanState.columnOrder, sort.field]);
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
@ -343,7 +390,7 @@ export const KanbanBoard = ({
) )
); );
} }
if (onColumnOrderChange) { if (onColumnOrderChange && sort.field === 'manual') {
const itemIds = columnTasks.map((t) => t.id); const itemIds = columnTasks.map((t) => t.id);
return ( return (
<> <>
@ -423,13 +470,17 @@ export const KanbanBoard = ({
<div className={cn('mb-2 flex items-center gap-2', toolbarLeft == null && 'justify-end')}> <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>} {toolbarLeft != null && <div className="min-w-0 flex-1">{toolbarLeft}</div>}
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<KanbanFilterPopover <div className="inline-flex items-center rounded-md border border-[var(--color-border)]">
filter={filter} <KanbanFilterPopover
sessions={sessions} filter={filter}
leadSessionId={leadSessionId} sessions={sessions}
members={members} leadSessionId={leadSessionId}
onFilterChange={onFilterChange} 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 ? ( {deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -543,7 +594,7 @@ export const KanbanBoard = ({
</> </>
); );
if (onColumnOrderChange) { if (onColumnOrderChange && sort.field === 'manual') {
return ( return (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} onDragEnd={handleDragEnd}>
{boardContent} {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>
);
};