From cc45549716120a9672ba4da9894fbc01a406c1c0 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 12 Apr 2026 22:17:18 +0300 Subject: [PATCH] feat(messages): add bottom sheet panel mode --- package.json | 4 +- pnpm-lock.yaml | 104 +- .../components/layout/TeamTabSectionNav.tsx | 13 +- .../components/team/TeamDetailView.tsx | 1550 +++++++++-------- .../team/messages/MessageComposer.tsx | 76 +- .../team/messages/MessagesPanel.tsx | 507 +++++- .../components/team/messages/StatusBlock.tsx | 41 +- .../team/sidebar/teamSidebarUiState.ts | 10 +- .../components/ui/MentionableTextarea.tsx | 161 +- src/renderer/index.css | 101 ++ src/renderer/store/slices/teamSlice.ts | 22 +- src/renderer/types/teamMessagesPanelMode.ts | 1 + .../team/messages/MessagesPanel.test.ts | 59 +- 13 files changed, 1753 insertions(+), 896 deletions(-) create mode 100644 src/renderer/types/teamMessagesPanelMode.ts diff --git a/package.json b/package.json index e5defa4b..9c9c5cdc 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ ] }, "dependencies": { + "@claude-teams/agent-graph": "workspace:*", "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.2", "@codemirror/lang-cpp": "^6.0.3", @@ -121,7 +122,6 @@ "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "agent-teams-controller": "workspace:*", - "@claude-teams/agent-graph": "workspace:*", "chokidar": "^4.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -139,12 +139,14 @@ "lucide-react": "^0.577.0", "mdast-util-to-hast": "^13.2.1", "mermaid": "^11.12.3", + "motion": "12.38.0", "node-diff3": "^3.2.0", "node-pty": "^1.1.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-grid-layout": "^2.2.2", "react-markdown": "^10.1.0", + "react-modal-sheet": "5.6.0", "react-resizable": "^3.1.3", "rehype-highlight": "^7.0.2", "rehype-raw": "^7.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 972d9968..53f11ab3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,6 +230,9 @@ importers: mermaid: specifier: ^11.12.3 version: 11.12.3 + motion: + specifier: 12.38.0 + version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) node-diff3: specifier: ^3.2.0 version: 3.2.0 @@ -248,6 +251,9 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.4) + react-modal-sheet: + specifier: 5.6.0 + version: 5.6.0(motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-resizable: specifier: ^3.1.3 version: 3.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -4796,6 +4802,7 @@ packages: '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xterm/addon-fit@0.11.0': resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} @@ -6755,6 +6762,20 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + framer-motion@12.38.0: + resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} @@ -8128,6 +8149,26 @@ packages: module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + motion-dom@12.38.0: + resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} + + motion-utils@12.36.0: + resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} + + motion@12.38.0: + resolution: {integrity: sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -9098,6 +9139,13 @@ packages: '@types/react': '>=18' react: '>=18' + react-modal-sheet@5.6.0: + resolution: {integrity: sha512-+WE2nVPdB/Jx0QbndIBqGvy6k0IXriW2lFaPeZSW1xOVri6rWhAwrSnArtsR1rxOxW8HBdAYeIPUcbjMvNeeyw==} + engines: {node: '>=20'} + peerDependencies: + motion: '>=11' + react: '>=16' + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -9138,6 +9186,15 @@ packages: '@types/react': optional: true + react-use-measure@2.1.7: + resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==} + peerDependencies: + react: '>=16.13' + react-dom: '>=16.13' + peerDependenciesMeta: + react-dom: + optional: true + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -15343,14 +15400,6 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@5.4.21(@types/node@22.19.15)(sass@1.98.0)(terser@5.46.0))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 5.4.21(@types/node@22.19.15)(sass@1.98.0)(terser@5.46.0) - '@vitest/mocker@3.2.4(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))': dependencies: '@vitest/spy': 3.2.4 @@ -18173,6 +18222,15 @@ snapshots: fraction.js@5.3.4: {} + framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + motion-dom: 12.38.0 + motion-utils: 12.36.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + fresh@0.5.2: {} fresh@2.0.0: {} @@ -19882,6 +19940,20 @@ snapshots: module-details-from-path@1.0.4: {} + motion-dom@12.38.0: + dependencies: + motion-utils: 12.36.0 + + motion-utils@12.36.0: {} + + motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + framer-motion: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + mrmime@2.0.1: {} ms@2.1.3: {} @@ -21137,6 +21209,14 @@ snapshots: transitivePeerDependencies: - supports-color + react-modal-sheet@5.6.0(motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + motion: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-use-measure: 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + transitivePeerDependencies: + - react-dom + react-refresh@0.17.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): @@ -21173,6 +21253,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react-use-measure@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + react@18.3.1: dependencies: loose-envify: 1.4.0 @@ -22817,7 +22903,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@22.19.15)(sass@1.98.0)(terser@5.46.0)) + '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 diff --git a/src/renderer/components/layout/TeamTabSectionNav.tsx b/src/renderer/components/layout/TeamTabSectionNav.tsx index e3466adf..2ba585ff 100644 --- a/src/renderer/components/layout/TeamTabSectionNav.tsx +++ b/src/renderer/components/layout/TeamTabSectionNav.tsx @@ -29,10 +29,15 @@ export const TeamTabSectionNav = ({ const buttonRef = useRef(null); const menuRef = useRef(null); const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 }); - const visibleSections = SECTIONS.filter( - (section) => - messagesPanelMode !== 'sidebar' || (section.id !== 'messages' && section.id !== 'claude-logs') - ); + const visibleSections = SECTIONS.filter((section) => { + if (messagesPanelMode === 'sidebar') { + return section.id !== 'messages' && section.id !== 'claude-logs'; + } + if (messagesPanelMode === 'bottom-sheet') { + return section.id !== 'messages'; + } + return true; + }); const handleNavigate = useCallback( (sectionId: string) => { diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 090b1ad3..0bb4d50c 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1,5 +1,4 @@ import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { ComponentProps } from 'react'; import { api } from '@renderer/api'; import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index'; @@ -36,8 +35,8 @@ import { type TaskChangeRequestOptions, } from '@renderer/utils/taskChangeRequest'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; +import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; -import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { AlertTriangle, @@ -73,6 +72,8 @@ import { TrashDialog } from './kanban/TrashDialog'; import { MemberDetailDialog } from './members/MemberDetailDialog'; import type { AddMemberEntry } from './dialogs/AddMemberDialog'; +import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; +import type { ComponentProps } from 'react'; const ProjectEditorOverlay = lazy(() => import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay })) @@ -92,13 +93,13 @@ import { TeamSidebarRail } from './sidebar/TeamSidebarRail'; import { ClaudeLogsSection } from './ClaudeLogsSection'; import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { ProcessesSection } from './ProcessesSection'; +import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps'; +import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { isLeadSessionMissing, shouldSuppressMissingLeadSessionFetch, } from './teamSessionFetchGuards'; -import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { TeamSessionsSection } from './TeamSessionsSection'; -import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps'; import type { KanbanFilterState } from './kanban/KanbanFilterPopover'; import type { KanbanSortState } from './kanban/KanbanSortPopover'; @@ -804,6 +805,9 @@ export const TeamDetailView = ({ const [editorOpen, setEditorOpen] = useState(false); const [graphOpen, setGraphOpen] = useState(false); const contentRef = useRef(null); + const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState( + null + ); const provisioningBannerRef = useRef(null); const wasProvisioningRef = useRef(false); const pendingReplyRefreshTimerRef = useRef(null); @@ -1159,9 +1163,12 @@ export const TeamDetailView = ({ side: 'top', }); - const toggleMessagesPanelMode = useCallback(() => { - setMessagesPanelMode(messagesPanelMode === 'sidebar' ? 'inline' : 'sidebar'); - }, [messagesPanelMode, setMessagesPanelMode]); + const changeMessagesPanelMode = useCallback( + (mode: TeamMessagesPanelMode) => { + setMessagesPanelMode(mode); + }, + [setMessagesPanelMode] + ); useEffect(() => { if (tabId) { @@ -1723,7 +1730,8 @@ export const TeamDetailView = ({ const sharedMessagesPanelProps = useMemo( () => ({ teamName, - onTogglePosition: toggleMessagesPanelMode, + onPositionChange: changeMessagesPanelMode, + mountPoint: messagesPanelMountPoint, members: activeMembers, tasks: data?.tasks ?? [], messages: data?.messages ?? [], @@ -1752,11 +1760,12 @@ export const TeamDetailView = ({ handleRestartTeam, handleSelectMember, handleTaskIdClick, + messagesPanelMountPoint, pendingRepliesByMember, teamName, teamSessionIds, timeWindow, - toggleMessagesPanelMode, + changeMessagesPanelMode, ] ); @@ -1925,431 +1934,763 @@ export const TeamDetailView = ({ -
-
- {headerColorSet ? ( +
+
+
+ {headerColorSet ? ( +
+ ) : null}
- ) : null} -
-
-
-

- {data.config.name} -

- {data.isAlive && ( - - - Running - - )} - {!data.isAlive && isTeamProvisioning && ( - - - Launching... - - )} + className={cn( + 'flex items-start justify-between gap-2', + headerColorSet && 'relative z-10' + )} + > +
+
+

+ {data.config.name} +

+ {data.isAlive && ( + + + Running + + )} + {!data.isAlive && isTeamProvisioning && ( + + + Launching... + + )} +
-
-
- {data.isAlive && ( +
+ {data.isAlive && ( + + + + + Stop team + + )} + + + + + Edit team + - Stop team + Delete team - )} - - - - - Edit team - - - - - - Delete team - +
-
- {data.config.description && ( -

- {data.config.description} -

- )} - {(data.config.projectPath || leadBranch) && ( -
- {data.config.projectPath && ( - - - - - - {data.config.projectPath - .replace(/\\/g, '/') - .split('/') - .filter(Boolean) - .pop() ?? data.config.projectPath} - - - - - {formatProjectPath(data.config.projectPath)} - - - - - - - - Open project in built-in editor - - - )} - {leadBranch && ( - - - {leadBranch} - - )} -
- )} - {(() => { - const currentPath = data.config.projectPath; - const history = data.config.projectPathHistory?.filter((p) => p !== currentPath); - if (!history || history.length === 0) return null; - return ( -
- - - Previous: {history.map((p) => formatProjectPath(p)).join(', ')} - -
- ); - })()} -
- - {!data.isAlive && !isTeamProvisioning ? ( - setLaunchDialogOpen(true)} - /> - ) : null} - -
- -
- - {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( -
- Failed to fully load kanban. Displaying safe data. -
- ) : null} - {reviewActionError ? ( -
- {reviewActionError} -
- ) : null} - - } - badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount} - defaultOpen - action={ -
- + + Open project in built-in editor + - - Graph - + )} + {leadBranch && ( + + + {leadBranch} + + )} +
+ )} + {(() => { + const currentPath = data.config.projectPath; + const history = data.config.projectPathHistory?.filter((p) => p !== currentPath); + if (!history || history.length === 0) return null; + return ( +
+ + + Previous: {history.map((p) => formatProjectPath(p)).join(', ')} + +
+ ); + })()} +
+ + {!data.isAlive && !isTeamProvisioning ? ( + setLaunchDialogOpen(true)} + /> + ) : null} + +
+ +
+ + {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( +
+ Failed to fully load kanban. Displaying safe data. +
+ ) : null} + {reviewActionError ? ( +
+ {reviewActionError} +
+ ) : null} + + } + badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount} + defaultOpen + action={ +
+ + +
+ } + > + +
+ + } + defaultOpen={false} + > + setKanbanFilter((prev) => ({ ...prev, sessionId: id }))} + projectPath={data.config.projectPath} + /> + + + } + badge={filteredTasks.length} + defaultOpen + forceOpen={kanbanSearch.trim().length > 0} + contentClassName="overflow-x-visible" + action={ -
- } - > - - - - } - defaultOpen={false} - > - setKanbanFilter((prev) => ({ ...prev, sessionId: id }))} - projectPath={data.config.projectPath} - /> - - - } - badge={filteredTasks.length} - defaultOpen - forceOpen={kanbanSearch.trim().length > 0} - contentClassName="overflow-x-visible" - action={ - - } - > - } - onRequestReview={(taskId) => { - void (async () => { - try { - await requestReview(teamName, taskId); - } catch { - // error via store - } - })(); - }} - onApprove={(taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { - op: 'set_column', - column: 'approved', - }); - } catch { - // error via store - } - })(); - }} - onRequestChanges={(taskId) => { - setRequestChangesTaskId(taskId); - }} - onMoveBackToDone={(taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { op: 'remove' }); - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - // error via store - } - })(); - }} - onStartTask={(taskId) => { - void (async () => { - try { - const result = await startTaskByUser(teamName, taskId); - if (data?.isAlive) { - const task = data.tasks.find((t) => t.id === taskId); - try { - if (result.notifiedOwner && task?.owner) { - await api.teams.processSend( - teamName, - `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` - ); - } else if (!result.notifiedOwner) { - const desc = task?.description?.trim() - ? `\nDescription: ${task.description.trim()}` + > + + } + onRequestReview={(taskId) => { + void (async () => { + try { + await requestReview(teamName, taskId); + } catch { + // error via store + } + })(); + }} + onApprove={(taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { + op: 'set_column', + column: 'approved', + }); + } catch { + // error via store + } + })(); + }} + onRequestChanges={(taskId) => { + setRequestChangesTaskId(taskId); + }} + onMoveBackToDone={(taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'remove' }); + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); + }} + onStartTask={(taskId) => { + void (async () => { + try { + const result = await startTaskByUser(teamName, taskId); + if (data?.isAlive) { + const task = data.tasks.find((t) => t.id === taskId); + try { + if (result.notifiedOwner && task?.owner) { + await api.teams.processSend( + teamName, + `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` + ); + } else if (!result.notifiedOwner) { + const desc = task?.description?.trim() + ? `\nDescription: ${task.description.trim()}` + : ''; + await api.teams.processSend( + teamName, + `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` + ); + } + } catch { + // best-effort + } + } + } catch { + // error via store + } + })(); + }} + onCompleteTask={(taskId) => { + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); + }} + onCancelTask={(taskId) => { + void (async () => { + try { + const task = data?.tasks.find((t) => t.id === taskId); + await updateTaskStatus(teamName, taskId, 'pending'); + + // Notify assignee directly via inbox — they'll see it immediately + if (task?.owner) { + try { + await api.teams.sendMessage(teamName, { + member: task.owner, + text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, + summary: `Task ${formatTaskDisplayLabel(task)} cancelled`, + }); + } catch { + // best-effort + } + } + + // Also notify team lead so they can reassign/coordinate + if (data?.isAlive) { + try { + const ownerSuffix = task?.owner + ? ` ${task.owner} has been notified to stop.` : ''; await api.teams.processSend( teamName, - `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` + `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` ); + } catch { + // best-effort } - } catch { - // best-effort } + } catch { + // error via store } - } catch { - // error via store + })(); + }} + onColumnOrderChange={(columnId, orderedTaskIds) => { + void (async () => { + try { + await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); + } catch { + // error via store + } + })(); + }} + onScrollToTask={(taskId) => { + const el = document.querySelector(`[data-task-id="${taskId}"]`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + el.classList.remove('kanban-card-focus-pulse'); + void (el as HTMLElement).offsetWidth; + el.classList.add('kanban-card-focus-pulse'); + el.addEventListener( + 'animationend', + () => el.classList.remove('kanban-card-focus-pulse'), + { once: true } + ); } - })(); - }} - onCompleteTask={(taskId) => { - void (async () => { - try { - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - // error via store - } - })(); - }} - onCancelTask={(taskId) => { - void (async () => { - try { - const task = data?.tasks.find((t) => t.id === taskId); - await updateTaskStatus(teamName, taskId, 'pending'); + }} + onTaskClick={(task) => setSelectedTask(task)} + onViewChanges={handleViewChanges} + onAddTask={(startImmediately) => + openCreateTaskDialog('', '', '', startImmediately) + } + onDeleteTask={handleDeleteTask} + deletedTaskCount={deletedTasks.length} + onOpenTrash={() => setTrashOpen(true)} + /> + - // Notify assignee directly via inbox — they'll see it immediately - if (task?.owner) { - try { - await api.teams.sendMessage(teamName, { - member: task.owner, - text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, - summary: `Task ${formatTaskDisplayLabel(task)} cancelled`, - }); - } catch { - // best-effort - } - } + } + defaultOpen={false} + > + + - // Also notify team lead so they can reassign/coordinate - if (data?.isAlive) { - try { - const ownerSuffix = task?.owner - ? ` ${task.owner} has been notified to stop.` - : ''; - await api.teams.processSend( - teamName, - `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` - ); - } catch { - // best-effort - } - } - } catch { - // error via store - } - })(); - }} - onColumnOrderChange={(columnId, orderedTaskIds) => { + {(data.processes?.length ?? 0) > 0 && ( + } + badge={data.processes.filter((p) => !p.stoppedAt).length} + headerExtra={ + data.processes.some((p) => !p.stoppedAt) ? ( + + + + + ) : null + } + defaultOpen + > + + + )} + + {messagesPanelMode !== 'sidebar' && } + + {messagesPanelMode === 'inline' && ( + + )} + + setRequestChangesTaskId(null)} + onSubmit={(comment, taskRefs) => { + if (!requestChangesTaskId) { + return; + } void (async () => { try { - await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); + await updateKanban(teamName, requestChangesTaskId, { + op: 'request_changes', + comment, + taskRefs, + }); + setRequestChangesTaskId(null); } catch { - // error via store + // error state is handled in the store and shown in the view } })(); }} + /> + + setSelectedMember(null)} + onSendMessage={() => { + const name = selectedMember?.name ?? ''; + setSelectedMember(null); + setSendDialogRecipient(name || undefined); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote(undefined); + setSendDialogOpen(true); + }} + onAssignTask={() => { + const name = selectedMember?.name ?? ''; + setSelectedMember(null); + openCreateTaskDialog('', '', name); + }} + onTaskClick={(task) => { + setSelectedMember(null); + setSelectedTask(task); + }} + onUpdateRole={async (memberName, role) => { + setUpdatingRoleLoading(true); + try { + await updateMemberRole(teamName, memberName, role); + // Optimistically update local selectedMember to reflect new role + setSelectedMember((prev) => { + if (prev?.name !== memberName) return prev; + const normalized = + typeof role === 'string' && role.trim() ? role.trim() : undefined; + return { ...prev, role: normalized }; + }); + } finally { + setUpdatingRoleLoading(false); + } + }} + updatingRole={updatingRoleLoading} + onRemoveMember={() => { + const name = selectedMember?.name; + if (!name) return; + setRemoveMemberConfirm(name); + }} + onViewMemberChanges={(memberName, filePath) => { + setSelectedMember(null); + setReviewDialogState({ + open: true, + mode: 'agent', + memberName, + initialFilePath: filePath, + }); + }} + /> + + + + !isLeadMember(m))} + isTeamAlive={data.isAlive && !isTeamProvisioning} + projectPath={data.config.projectPath} + onClose={() => setEditDialogOpen(false)} + onSaved={() => void selectTeam(teamName)} + /> + + m.name)} + existingMembers={membersWithLiveBranches} + projectPath={data.config.projectPath} + adding={addingMemberLoading} + onClose={() => setAddMemberDialogOpen(false)} + onAdd={(entries: AddMemberEntry[]) => { + setAddingMemberLoading(true); + void (async () => { + try { + for (const entry of entries) { + await addMember(teamName, { + name: entry.name, + role: entry.role, + workflow: entry.workflow, + providerId: entry.providerId, + model: entry.model, + effort: entry.effort, + }); + } + setAddMemberDialogOpen(false); + } catch { + // error shown via store + } finally { + setAddingMemberLoading(false); + } + })(); + }} + /> + + { + if (!open) setRemoveMemberConfirm(null); + }} + > + + + Remove member + + Remove “{removeMemberConfirm}” from the team? Tasks and messages + will be preserved, but this name cannot be reused. + + + + + + + + + + + + + Delete team + + Delete team “{data.config.name}”? This action is irreversible. All + team data and tasks will be deleted. + + + + + + + + + + setLaunchDialogOpen(false)} + onLaunch={async (request) => { + await launchTeam(request); + }} + /> + + { + void (async () => { + const sentAtMs = Date.now(); + setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); + try { + await sendTeamMessage(teamName, { + member, + text, + summary, + attachments, + actionMode, + taskRefs, + }); + } catch { + setPendingRepliesByMember((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + } + })(); + }} + onClose={() => { + setSendDialogOpen(false); + setReplyQuote(undefined); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + }} + /> + + setSelectedTask(null)} onScrollToTask={(taskId) => { + setSelectedTask(null); const el = document.querySelector(`[data-task-id="${taskId}"]`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); @@ -2363,389 +2704,66 @@ export const TeamDetailView = ({ ); } }} - onTaskClick={(task) => setSelectedTask(task)} - onViewChanges={handleViewChanges} - onAddTask={(startImmediately) => openCreateTaskDialog('', '', '', startImmediately)} - onDeleteTask={handleDeleteTask} - deletedTaskCount={deletedTasks.length} - onOpenTrash={() => setTrashOpen(true)} - /> - - - } - defaultOpen={false} - > - - - - {(data.processes?.length ?? 0) > 0 && ( - } - badge={data.processes.filter((p) => !p.stoppedAt).length} - headerExtra={ - data.processes.some((p) => !p.stoppedAt) ? ( - - - - - ) : null - } - defaultOpen - > - - - )} - - {messagesPanelMode !== 'sidebar' && } - - {messagesPanelMode === 'inline' && ( - - )} - - setRequestChangesTaskId(null)} - onSubmit={(comment, taskRefs) => { - if (!requestChangesTaskId) { - return; - } - void (async () => { - try { - await updateKanban(teamName, requestChangesTaskId, { - op: 'request_changes', - comment, - taskRefs, - }); - setRequestChangesTaskId(null); - } catch { - // error state is handled in the store and shown in the view - } - })(); - }} - /> - - setSelectedMember(null)} - onSendMessage={() => { - const name = selectedMember?.name ?? ''; - setSelectedMember(null); - setSendDialogRecipient(name || undefined); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote(undefined); - setSendDialogOpen(true); - }} - onAssignTask={() => { - const name = selectedMember?.name ?? ''; - setSelectedMember(null); - openCreateTaskDialog('', '', name); - }} - onTaskClick={(task) => { - setSelectedMember(null); - setSelectedTask(task); - }} - onUpdateRole={async (memberName, role) => { - setUpdatingRoleLoading(true); - try { - await updateMemberRole(teamName, memberName, role); - // Optimistically update local selectedMember to reflect new role - setSelectedMember((prev) => { - if (prev?.name !== memberName) return prev; - const normalized = - typeof role === 'string' && role.trim() ? role.trim() : undefined; - return { ...prev, role: normalized }; - }); - } finally { - setUpdatingRoleLoading(false); - } - }} - updatingRole={updatingRoleLoading} - onRemoveMember={() => { - const name = selectedMember?.name; - if (!name) return; - setRemoveMemberConfirm(name); - }} - onViewMemberChanges={(memberName, filePath) => { - setSelectedMember(null); - setReviewDialogState({ - open: true, - mode: 'agent', - memberName, - initialFilePath: filePath, - }); - }} - /> - - - - !isLeadMember(m))} - isTeamAlive={data.isAlive && !isTeamProvisioning} - projectPath={data.config.projectPath} - onClose={() => setEditDialogOpen(false)} - onSaved={() => void selectTeam(teamName)} - /> - - m.name)} - existingMembers={membersWithLiveBranches} - projectPath={data.config.projectPath} - adding={addingMemberLoading} - onClose={() => setAddMemberDialogOpen(false)} - onAdd={(entries: AddMemberEntry[]) => { - setAddingMemberLoading(true); - void (async () => { - try { - for (const entry of entries) { - await addMember(teamName, { - name: entry.name, - role: entry.role, - workflow: entry.workflow, - providerId: entry.providerId, - model: entry.model, - effort: entry.effort, - }); + onOwnerChange={(taskId, owner) => { + void (async () => { + try { + await updateTaskOwner(teamName, taskId, owner); + } catch { + // error via store } - setAddMemberDialogOpen(false); - } catch { - // error shown via store - } finally { - setAddingMemberLoading(false); - } - })(); - }} - /> + })(); + }} + onViewChanges={handleViewChangesForFile} + onOpenInEditor={(filePath) => { + const { revealFileInEditor } = useStore.getState(); + revealFileInEditor(filePath); + }} + onDeleteTask={handleDeleteTask} + /> - { - if (!open) setRemoveMemberConfirm(null); - }} - > - - - Remove member - - Remove “{removeMemberConfirm}” from the team? Tasks and messages - will be preserved, but this name cannot be reused. - - - - - - - - + setTrashOpen(false)} + onRestore={(taskId) => { + void (async () => { + try { + await restoreTask(teamName, taskId); + } catch { + // error via store + } + })(); + }} + /> - - - - Delete team - - Delete team “{data.config.name}”? This action is irreversible. All - team data and tasks will be deleted. - - - - - - - - - - setLaunchDialogOpen(false)} - onLaunch={async (request) => { - await launchTeam(request); - }} - /> - - { - void (async () => { - const sentAtMs = Date.now(); - setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); - try { - await sendTeamMessage(teamName, { - member, - text, - summary, - attachments, - actionMode, - taskRefs, - }); - } catch { - setPendingRepliesByMember((prev) => { - if (prev[member] !== sentAtMs) return prev; - const next = { ...prev }; - delete next[member]; - return next; - }); - } - })(); - }} - onClose={() => { - setSendDialogOpen(false); - setReplyQuote(undefined); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - }} - /> - - setSelectedTask(null)} - onScrollToTask={(taskId) => { - setSelectedTask(null); - const el = document.querySelector(`[data-task-id="${taskId}"]`); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - el.classList.remove('kanban-card-focus-pulse'); - void (el as HTMLElement).offsetWidth; - el.classList.add('kanban-card-focus-pulse'); - el.addEventListener( - 'animationend', - () => el.classList.remove('kanban-card-focus-pulse'), - { once: true } - ); + + setReviewDialogState((prev) => ({ + ...prev, + open, + ...(open + ? {} + : { initialFilePath: undefined, taskChangeRequestOptions: undefined }), + })) } - }} - onOwnerChange={(taskId, owner) => { - void (async () => { - try { - await updateTaskOwner(teamName, taskId, owner); - } catch { - // error via store - } - })(); - }} - onViewChanges={handleViewChangesForFile} - onOpenInEditor={(filePath) => { - const { revealFileInEditor } = useStore.getState(); - revealFileInEditor(filePath); - }} - onDeleteTask={handleDeleteTask} - /> - - setTrashOpen(false)} - onRestore={(taskId) => { - void (async () => { - try { - await restoreTask(teamName, taskId); - } catch { - // error via store - } - })(); - }} - /> - - - setReviewDialogState((prev) => ({ - ...prev, - open, - ...(open - ? {} - : { initialFilePath: undefined, taskChangeRequestOptions: undefined }), - })) - } - teamName={teamName} - mode={reviewDialogState.mode} - memberName={reviewDialogState.memberName} - taskId={reviewDialogState.taskId} - initialFilePath={reviewDialogState.initialFilePath} - taskChangeRequestOptions={reviewDialogState.taskChangeRequestOptions} - projectPath={data.config.projectPath} - onEditorAction={handleEditorAction} + teamName={teamName} + mode={reviewDialogState.mode} + memberName={reviewDialogState.memberName} + taskId={reviewDialogState.taskId} + initialFilePath={reviewDialogState.initialFilePath} + taskChangeRequestOptions={reviewDialogState.taskChangeRequestOptions} + projectPath={data.config.projectPath} + onEditorAction={handleEditorAction} + /> +
+
+ {messagesPanelMode === 'bottom-sheet' && ( + + )}
diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 2f2fa1e1..1776a26a 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -41,6 +41,7 @@ import type { interface MessageComposerProps { teamName: string; members: ResolvedTeamMember[]; + layout?: 'default' | 'compact'; isTeamAlive?: boolean; sending: boolean; sendError: string | null; @@ -67,6 +68,7 @@ interface MessageComposerProps { export const MessageComposer = ({ teamName, members, + layout = 'default', isTeamAlive, sending, sendError, @@ -443,10 +445,27 @@ export const MessageComposer = ({ const remaining = MAX_TEXT_LENGTH - trimmed.length; const hasAttachmentPreviewContent = draft.attachments.length > 0 || Boolean(draft.attachmentError ?? fileRestrictionError); + const isCompactLayout = layout === 'compact'; + const compactFooterNotice = slashCommandRestrictionReason ? ( + + + {slashCommandRestrictionReason} + + ) : sendError ? ( + + + {sendError} + + ) : lastResult?.deduplicated ? ( + + + Reused recent cross-team request + + ) : null; return (
-
+
{isLeadRecipient ? ( <> @@ -807,11 +826,17 @@ export const MessageComposer = ({ onShiftTab={handleCycleActionMode} dismissMentionsRef={dismissMentionsRef} extraTips={['Tip: You can use "/" to run any Claude commands.']} - minRows={2} + surfaceClassName="message-composer-shell message-composer-orbit-surface border border-transparent bg-[var(--color-surface-raised)] shadow-[0_8px_24px_rgba(0,0,0,0.18),inset_0_1px_0_rgba(255,255,255,0.03)]" + surfaceDecoration="orbit-border" + surfaceFadeColor="var(--color-surface-raised)" + className="border-transparent shadow-none" + minRows={isCompactLayout ? 1 : 2} maxRows={6} maxLength={MAX_TEXT_LENGTH} disabled={sending} hintText={crossTeamHintText} + showHint={!isCompactLayout} + cornerActionInset={isCompactLayout ? 'compact' : 'default'} cornerActionLeft={ } footerRight={ -
- {slashCommandRestrictionReason ? ( - - - {slashCommandRestrictionReason} - - ) : sendError ? ( - - - {sendError} - - ) : lastResult?.deduplicated ? ( - - - Reused recent cross-team request - - ) : null} - {remaining < 200 ? ( - - {remaining} chars left - - ) : null} - {draft.isSaved ? ( - Saved - ) : null} -
+ isCompactLayout ? ( + compactFooterNotice + ) : ( +
+ {compactFooterNotice} + {remaining < 200 ? ( + + {remaining} chars left + + ) : null} + {draft.isSaved ? ( + Saved + ) : null} +
+ ) } />
diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 71ec1294..f2c4e44b 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -1,4 +1,5 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { Sheet, type SheetRef } from 'react-modal-sheet'; import { api } from '@renderer/api'; import { Badge } from '@renderer/components/ui/badge'; @@ -9,21 +10,24 @@ import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useStore } from '@renderer/store'; import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; -import { useShallow } from 'zustand/react/shallow'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; -import { createLogger } from '@shared/utils/logger'; import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics'; +import { createLogger } from '@shared/utils/logger'; import { CheckCheck, ChevronsDownUp, ChevronsUpDown, MessageSquare, + PanelBottom, + PanelBottomClose, + PanelBottomOpen, PanelLeft, PanelLeftClose, Search, X, } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { ActivityTimeline } from '../activity/ActivityTimeline'; import { getThoughtGroupKey, groupTimelineItems } from '../activity/LeadThoughtsGroup'; @@ -41,6 +45,7 @@ import { StatusBlock } from './StatusBlock'; import type { TimelineItem } from '../activity/LeadThoughtsGroup'; import type { ActionMode } from './ActionModeSelector'; import type { MessagesFilterState } from './MessagesFilterPopover'; +import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; import type { InboxMessage, ResolvedTeamMember, TaskRef, TeamTaskWithKanban } from '@shared/types'; interface TimeWindow { @@ -51,11 +56,16 @@ interface TimeWindow { const logger = createLogger('Component:MessagesPanel'); const MESSAGES_PANEL_FILTER_WARN_MS = 8; const MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS = 6; +const BOTTOM_SHEET_HEADER_HEIGHT = 40; +const BOTTOM_SHEET_COLLAPSED_SNAP_INDEX = 1; +const BOTTOM_SHEET_COMPOSER_SNAP_INDEX = 2; +const BOTTOM_SHEET_FULL_SNAP_INDEX = 4; interface MessagesPanelProps { teamName: string; - position: 'sidebar' | 'inline'; - onTogglePosition: () => void; + position: TeamMessagesPanelMode; + onPositionChange: (position: TeamMessagesPanelMode) => void; + mountPoint?: Element | null; /** Active (non-removed) members. */ members: ResolvedTeamMember[]; /** All team tasks. */ @@ -95,7 +105,8 @@ interface MessagesPanelProps { export const MessagesPanel = memo(function MessagesPanel({ teamName, position, - onTogglePosition, + onPositionChange, + mountPoint, members, tasks, messages, @@ -207,6 +218,8 @@ export const MessagesPanel = memo(function MessagesPanel({ const composerTextareaRef = useRef(null); const sidebarScrollRef = useRef(null); + const bottomSheetRef = useRef(null); + const bottomSheetStickyTopRef = useRef(null); const handleExpandContent = useCallback(() => { // no-op: user is reading expanded content, not composing }, []); @@ -224,15 +237,20 @@ export const MessagesPanel = memo(function MessagesPanel({ const [messagesCollapsed, setMessagesCollapsed] = useState( initialSidebarStateRef.current.messagesCollapsed ); - const [sidebarSearchVisible, setSidebarSearchVisible] = useState( - initialSidebarStateRef.current.sidebarSearchVisible + const [messagesSearchBarVisible, setMessagesSearchBarVisible] = useState( + initialSidebarStateRef.current.messagesSearchBarVisible ); const [expandedItemKey, setExpandedItemKey] = useState( initialSidebarStateRef.current.expandedItemKey ); - const [sidebarScrollTop, setSidebarScrollTop] = useState( - initialSidebarStateRef.current.sidebarScrollTop + const [messagesScrollTop, setMessagesScrollTop] = useState( + initialSidebarStateRef.current.messagesScrollTop ); + const [bottomSheetSnapIndex, setBottomSheetSnapIndex] = useState( + initialSidebarStateRef.current.bottomSheetSnapIndex + ); + const [bottomSheetStickyTopHeight, setBottomSheetStickyTopHeight] = useState(196); + const [bottomSheetMountHeight, setBottomSheetMountHeight] = useState(0); useEffect(() => { initialSidebarStateRef.current = getTeamMessagesSidebarUiState(teamName); @@ -240,9 +258,10 @@ export const MessagesPanel = memo(function MessagesPanel({ setMessagesFilter(initialSidebarStateRef.current.messagesFilter); setMessagesFilterOpen(initialSidebarStateRef.current.messagesFilterOpen); setMessagesCollapsed(initialSidebarStateRef.current.messagesCollapsed); - setSidebarSearchVisible(initialSidebarStateRef.current.sidebarSearchVisible); + setMessagesSearchBarVisible(initialSidebarStateRef.current.messagesSearchBarVisible); setExpandedItemKey(initialSidebarStateRef.current.expandedItemKey); - setSidebarScrollTop(initialSidebarStateRef.current.sidebarScrollTop); + setMessagesScrollTop(initialSidebarStateRef.current.messagesScrollTop); + setBottomSheetSnapIndex(initialSidebarStateRef.current.bottomSheetSnapIndex); }, [teamName]); useEffect(() => { @@ -251,9 +270,10 @@ export const MessagesPanel = memo(function MessagesPanel({ messagesFilter, messagesFilterOpen, messagesCollapsed, - sidebarSearchVisible, + messagesSearchBarVisible, expandedItemKey, - sidebarScrollTop, + messagesScrollTop, + bottomSheetSnapIndex, }); }, [ teamName, @@ -261,17 +281,52 @@ export const MessagesPanel = memo(function MessagesPanel({ messagesFilter, messagesFilterOpen, messagesCollapsed, - sidebarSearchVisible, + messagesSearchBarVisible, expandedItemKey, - sidebarScrollTop, + messagesScrollTop, + bottomSheetSnapIndex, ]); useLayoutEffect(() => { if (position !== 'sidebar') return; const el = sidebarScrollRef.current; if (!el) return; - el.scrollTop = sidebarScrollTop; - }, [position, sidebarScrollTop]); + el.scrollTop = messagesScrollTop; + }, [position, messagesScrollTop]); + + useLayoutEffect(() => { + if (position !== 'bottom-sheet' || typeof ResizeObserver === 'undefined') return; + + const mountPointElement = mountPoint instanceof HTMLElement ? mountPoint : null; + const observedEntries: Array<[Element | null, (height: number) => void]> = [ + [bottomSheetStickyTopRef.current, setBottomSheetStickyTopHeight], + [mountPointElement, setBottomSheetMountHeight], + ]; + const observers: ResizeObserver[] = []; + + for (const [element, setHeight] of observedEntries) { + if (!element) continue; + + const updateHeight = () => { + const nextHeight = Math.ceil(element.getBoundingClientRect().height); + if (nextHeight > 0) { + setHeight(nextHeight); + } + }; + + updateHeight(); + + const observer = new ResizeObserver(() => { + updateHeight(); + }); + observer.observe(element); + observers.push(observer); + } + + return () => { + observers.forEach((observer) => observer.disconnect()); + }; + }, [position, mountPoint]); const filteredMessages = useMemo(() => { const startedAt = performance.now(); @@ -348,7 +403,7 @@ export const MessagesPanel = memo(function MessagesPanel({ ); } return result; - }, [expandedItemKey, activityTimelineMessages]); + }, [expandedItemKey, activityTimelineMessages, teamName]); // Auto-clear stale expanded key useEffect(() => { @@ -461,6 +516,60 @@ export const MessagesPanel = memo(function MessagesPanel({ [teamName, sendCrossTeamMessage] ); + const moveToInline = useCallback(() => { + onPositionChange('inline'); + }, [onPositionChange]); + + const moveToSidebar = useCallback(() => { + onPositionChange('sidebar'); + }, [onPositionChange]); + + const moveToBottomSheet = useCallback(() => { + setBottomSheetSnapIndex(BOTTOM_SHEET_COMPOSER_SNAP_INDEX); + onPositionChange('bottom-sheet'); + }, [onPositionChange]); + + const snapBottomSheetTo = useCallback((snapIndex: number) => { + setBottomSheetSnapIndex(snapIndex); + bottomSheetRef.current?.snapTo(snapIndex); + }, []); + + const toggleBottomSheetExpansion = useCallback(() => { + if (bottomSheetSnapIndex === BOTTOM_SHEET_COLLAPSED_SNAP_INDEX) { + snapBottomSheetTo(BOTTOM_SHEET_COMPOSER_SNAP_INDEX); + return; + } + snapBottomSheetTo(BOTTOM_SHEET_COLLAPSED_SNAP_INDEX); + }, [bottomSheetSnapIndex, snapBottomSheetTo]); + + const bottomSheetSnapPoints = useMemo(() => { + const maxOpenHeight = + bottomSheetMountHeight > 0 + ? Math.max(bottomSheetMountHeight - 1, 96) + : Number.POSITIVE_INFINITY; + const collapsedHeight = Math.min(BOTTOM_SHEET_HEADER_HEIGHT, maxOpenHeight); + const composerHeight = Math.min( + Math.max(collapsedHeight + bottomSheetStickyTopHeight, collapsedHeight + 120), + maxOpenHeight + ); + const centeredHeight = Math.min( + Math.max( + bottomSheetMountHeight > 0 ? Math.round(bottomSheetMountHeight * 0.58) : 520, + composerHeight + 140 + ), + maxOpenHeight + ); + + return [0, collapsedHeight, composerHeight, centeredHeight, 1]; + }, [bottomSheetMountHeight, bottomSheetStickyTopHeight]); + + const normalizedBottomSheetSnapIndex = useMemo(() => { + return Math.min( + Math.max(bottomSheetSnapIndex, BOTTOM_SHEET_COLLAPSED_SNAP_INDEX), + BOTTOM_SHEET_FULL_SNAP_INDEX + ); + }, [bottomSheetSnapIndex]); + // ---- Shared content (used in both modes) ---- const searchAndFilterControls = (
@@ -602,9 +711,9 @@ export const MessagesPanel = memo(function MessagesPanel({ // ---- Sidebar mode ---- if (position === 'sidebar') { return ( -
+
{/* Header */} -
+
Messages {filteredMessages.length > 0 && ( @@ -650,6 +759,7 @@ export const MessagesPanel = memo(function MessagesPanel({ size="sm" className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]" onClick={() => setMessagesCollapsed((v) => !v)} + aria-label={messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'} > {messagesCollapsed ? : } @@ -664,13 +774,16 @@ export const MessagesPanel = memo(function MessagesPanel({ variant="ghost" size="sm" className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]" - onClick={() => setSidebarSearchVisible((v) => !v)} + onClick={() => setMessagesSearchBarVisible((v) => !v)} + aria-label={ + messagesSearchBarVisible ? 'Hide message search' : 'Show message search' + } > - {sidebarSearchVisible ? : } + {messagesSearchBarVisible ? : } - {sidebarSearchVisible ? 'Hide search' : 'Search messages'} + {messagesSearchBarVisible ? 'Hide search' : 'Search messages'} @@ -679,7 +792,8 @@ export const MessagesPanel = memo(function MessagesPanel({ variant="ghost" size="sm" className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]" - onClick={onTogglePosition} + onClick={moveToInline} + aria-label="Move messages to inline panel" > @@ -689,7 +803,7 @@ export const MessagesPanel = memo(function MessagesPanel({
{/* Search & filter bar (toggleable) */} - {sidebarSearchVisible && ( + {messagesSearchBarVisible && (
{searchAndFilterControls}
@@ -698,7 +812,7 @@ export const MessagesPanel = memo(function MessagesPanel({
setSidebarScrollTop(e.currentTarget.scrollTop)} + onScroll={(e) => setMessagesScrollTop(e.currentTarget.scrollTop)} >