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:
parent
8216d25eac
commit
c5c41d2a0d
19 changed files with 488 additions and 250 deletions
|
|
@ -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 } : {}),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
140
src/renderer/components/team/kanban/KanbanSortPopover.tsx
Normal file
140
src/renderer/components/team/kanban/KanbanSortPopover.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Loading…
Reference in a new issue