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),
|
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 } : {}),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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':
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
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