feat(messages): add bottom sheet panel mode
This commit is contained in:
parent
02d516cb4e
commit
cc45549716
13 changed files with 1753 additions and 896 deletions
|
|
@ -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",
|
||||
|
|
|
|||
104
pnpm-lock.yaml
104
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
|
||||
|
|
|
|||
|
|
@ -29,10 +29,15 @@ export const TeamTabSectionNav = ({
|
|||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(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) => {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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 ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] text-amber-300">
|
||||
<AlertCircle size={10} className="shrink-0" />
|
||||
{slashCommandRestrictionReason}
|
||||
</span>
|
||||
) : sendError ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-red-500/10 px-1.5 py-0.5 text-[10px] text-red-400">
|
||||
<AlertCircle size={10} className="shrink-0" />
|
||||
{sendError}
|
||||
</span>
|
||||
) : lastResult?.deduplicated ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] text-amber-300">
|
||||
<Check size={10} className="shrink-0" />
|
||||
Reused recent cross-team request
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative mb-3 pb-3"
|
||||
className={cn('relative', isCompactLayout ? 'pb-1' : 'mb-3 pb-3')}
|
||||
role="group"
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
|
|
@ -454,7 +473,7 @@ export const MessageComposer = ({
|
|||
onDrop={handleDropWrapper}
|
||||
onPaste={handlePasteWrapper}
|
||||
>
|
||||
<div className="mb-1 space-y-2">
|
||||
<div className={cn('mb-1', isCompactLayout ? 'space-y-1.5' : 'space-y-2')}>
|
||||
<div className="flex items-center gap-2">
|
||||
{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={
|
||||
<ActionModeSelector
|
||||
value={actionMode}
|
||||
|
|
@ -859,34 +884,23 @@ export const MessageComposer = ({
|
|||
</div>
|
||||
}
|
||||
footerRight={
|
||||
<div className="flex items-center gap-2">
|
||||
{slashCommandRestrictionReason ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] text-amber-300">
|
||||
<AlertCircle size={10} className="shrink-0" />
|
||||
{slashCommandRestrictionReason}
|
||||
</span>
|
||||
) : sendError ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-red-500/10 px-1.5 py-0.5 text-[10px] text-red-400">
|
||||
<AlertCircle size={10} className="shrink-0" />
|
||||
{sendError}
|
||||
</span>
|
||||
) : lastResult?.deduplicated ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] text-amber-300">
|
||||
<Check size={10} className="shrink-0" />
|
||||
Reused recent cross-team request
|
||||
</span>
|
||||
) : null}
|
||||
{remaining < 200 ? (
|
||||
<span
|
||||
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
|
||||
>
|
||||
{remaining} chars left
|
||||
</span>
|
||||
) : null}
|
||||
{draft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
|
||||
) : null}
|
||||
</div>
|
||||
isCompactLayout ? (
|
||||
compactFooterNotice
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
{compactFooterNotice}
|
||||
{remaining < 200 ? (
|
||||
<span
|
||||
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
|
||||
>
|
||||
{remaining} chars left
|
||||
</span>
|
||||
) : null}
|
||||
{draft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<HTMLTextAreaElement | null>(null);
|
||||
const sidebarScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const bottomSheetRef = useRef<SheetRef>(null);
|
||||
const bottomSheetStickyTopRef = useRef<HTMLDivElement | null>(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<string | null>(
|
||||
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 = (
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -602,9 +711,9 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
// ---- Sidebar mode ----
|
||||
if (position === 'sidebar') {
|
||||
return (
|
||||
<div className="flex size-full flex-col overflow-hidden bg-[var(--color-surface)]">
|
||||
<div className="flex size-full flex-col overflow-hidden bg-[var(--color-surface-sidebar)]">
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-section-bg)] px-3 py-2">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-sidebar)] px-3 py-2">
|
||||
<MessageSquare size={14} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<span className="text-sm font-medium text-[var(--color-text)]">Messages</span>
|
||||
{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 ? <ChevronsUpDown size={14} /> : <ChevronsDownUp size={14} />}
|
||||
</Button>
|
||||
|
|
@ -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 ? <X size={14} /> : <Search size={14} />}
|
||||
{messagesSearchBarVisible ? <X size={14} /> : <Search size={14} />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{sidebarSearchVisible ? 'Hide search' : 'Search messages'}
|
||||
{messagesSearchBarVisible ? 'Hide search' : 'Search messages'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
|
|
@ -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"
|
||||
>
|
||||
<PanelLeftClose size={14} />
|
||||
</Button>
|
||||
|
|
@ -689,7 +803,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
</div>
|
||||
</div>
|
||||
{/* Search & filter bar (toggleable) */}
|
||||
{sidebarSearchVisible && (
|
||||
{messagesSearchBarVisible && (
|
||||
<div className="shrink-0 border-b border-[var(--color-border)] px-3 py-1.5">
|
||||
{searchAndFilterControls}
|
||||
</div>
|
||||
|
|
@ -698,7 +812,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
<div
|
||||
ref={sidebarScrollRef}
|
||||
className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden pb-14 pr-3 pt-2"
|
||||
onScroll={(e) => setSidebarScrollTop(e.currentTarget.scrollTop)}
|
||||
onScroll={(e) => setMessagesScrollTop(e.currentTarget.scrollTop)}
|
||||
>
|
||||
<div className="pl-3">
|
||||
<MessageComposer
|
||||
|
|
@ -780,6 +894,295 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
);
|
||||
}
|
||||
|
||||
if (position === 'bottom-sheet') {
|
||||
if (!mountPoint) {
|
||||
return <div className="hidden" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
const isBottomSheetCollapsed =
|
||||
normalizedBottomSheetSnapIndex === BOTTOM_SHEET_COLLAPSED_SNAP_INDEX;
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
ref={bottomSheetRef}
|
||||
isOpen
|
||||
onClose={moveToInline}
|
||||
mountPoint={mountPoint}
|
||||
avoidKeyboard={false}
|
||||
detent="full"
|
||||
snapPoints={bottomSheetSnapPoints}
|
||||
initialSnap={normalizedBottomSheetSnapIndex}
|
||||
onSnap={setBottomSheetSnapIndex}
|
||||
disableDismiss
|
||||
disableScrollLocking
|
||||
style={{ zIndex: 30 }}
|
||||
className="!pointer-events-none !absolute !inset-0"
|
||||
unstyled
|
||||
>
|
||||
<Sheet.Container
|
||||
unstyled
|
||||
className="flex max-h-full w-full flex-col overflow-hidden rounded-t-[20px] border border-[var(--color-border)] bg-[var(--color-surface-sidebar)] shadow-[0_-18px_48px_rgba(0,0,0,0.35)]"
|
||||
>
|
||||
<Sheet.Header
|
||||
unstyled
|
||||
className="shrink-0 cursor-grab select-none border-b border-[var(--color-border)] bg-[var(--color-surface-sidebar)] active:cursor-grabbing"
|
||||
>
|
||||
<div className="relative h-10 px-3">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-1 flex justify-center">
|
||||
<Sheet.DragIndicator
|
||||
className="!h-1 !w-9 cursor-grab !rounded-full active:cursor-grabbing"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-text-muted) 45%, transparent)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-full items-center gap-1.5">
|
||||
<MessageSquare size={13} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<span className="text-[13px] font-medium text-[var(--color-text)]">Messages</span>
|
||||
{filteredMessages.length > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="px-1 py-0 text-[9px] font-normal leading-none"
|
||||
>
|
||||
{filteredMessages.length}
|
||||
</Badge>
|
||||
)}
|
||||
{messagesUnreadCount > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-blue-500/20 px-1 py-0 text-[9px] font-normal leading-none text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
{messagesUnreadCount} new
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{messagesUnreadCount} unread</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div
|
||||
className="ml-auto flex items-center gap-1"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{messagesUnreadCount > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-[22px] p-0 text-blue-400 hover:bg-blue-500/10 hover:text-blue-300"
|
||||
onClick={handleMarkAllRead}
|
||||
aria-label="Mark all messages as read"
|
||||
>
|
||||
<CheckCheck size={13} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Mark all as read</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-[22px] p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => setMessagesCollapsed((value) => !value)}
|
||||
aria-label={
|
||||
messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'
|
||||
}
|
||||
>
|
||||
{messagesCollapsed ? (
|
||||
<ChevronsUpDown size={14} />
|
||||
) : (
|
||||
<ChevronsDownUp size={14} />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-[22px] p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => setMessagesSearchBarVisible((value) => !value)}
|
||||
aria-label={
|
||||
messagesSearchBarVisible ? 'Hide message search' : 'Show message search'
|
||||
}
|
||||
>
|
||||
{messagesSearchBarVisible ? <X size={14} /> : <Search size={14} />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{messagesSearchBarVisible ? 'Hide search' : 'Search messages'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-[22px] p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={toggleBottomSheetExpansion}
|
||||
aria-label={
|
||||
isBottomSheetCollapsed
|
||||
? 'Expand messages bottom sheet'
|
||||
: 'Collapse messages bottom sheet'
|
||||
}
|
||||
>
|
||||
{isBottomSheetCollapsed ? (
|
||||
<PanelBottomOpen size={14} />
|
||||
) : (
|
||||
<PanelBottomClose size={14} />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{isBottomSheetCollapsed ? 'Expand sheet' : 'Collapse sheet'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-[22px] p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={moveToInline}
|
||||
aria-label="Move messages to inline panel"
|
||||
>
|
||||
<PanelBottom size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Move to inline</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-[22px] p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={moveToSidebar}
|
||||
aria-label="Move messages to sidebar"
|
||||
>
|
||||
<PanelLeft size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Move to sidebar</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Sheet.Header>
|
||||
{!isBottomSheetCollapsed && (
|
||||
<Sheet.Content
|
||||
className="min-h-0 bg-[var(--color-surface-sidebar)]"
|
||||
scrollClassName="flex min-h-full flex-col"
|
||||
disableDrag={(state) => state.scrollPosition !== 'top'}
|
||||
>
|
||||
<div
|
||||
ref={bottomSheetStickyTopRef}
|
||||
className="sticky top-0 z-[1] shrink-0 border-b border-[var(--color-border)] backdrop-blur"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-sidebar)',
|
||||
}}
|
||||
>
|
||||
{messagesSearchBarVisible && (
|
||||
<div className="border-b border-[var(--color-border)] px-3 py-2">
|
||||
{searchAndFilterControls}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 pb-3 pt-3">
|
||||
<MessageComposer
|
||||
teamName={teamName}
|
||||
layout="compact"
|
||||
members={members}
|
||||
isTeamAlive={isTeamAlive}
|
||||
sending={sendingMessage}
|
||||
sendError={sendMessageError}
|
||||
lastResult={lastSendMessageResult}
|
||||
textareaRef={composerTextareaRef}
|
||||
onSend={handleSend}
|
||||
onCrossTeamSend={handleCrossTeamSend}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 px-3 pt-2">
|
||||
<StatusBlock
|
||||
members={members}
|
||||
tasks={tasks}
|
||||
messages={effectiveMessages}
|
||||
pendingRepliesByMember={pendingRepliesByMember}
|
||||
layout="flow"
|
||||
position="inline"
|
||||
onMemberClick={onMemberClick}
|
||||
onTaskClick={onTaskClick}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 px-3 pb-4 pt-2">
|
||||
<ActivityTimeline
|
||||
messages={activityTimelineMessages}
|
||||
teamName={teamName}
|
||||
members={members}
|
||||
readState={readState}
|
||||
allCollapsed={messagesCollapsed}
|
||||
expandOverrides={expandedSet}
|
||||
onToggleExpandOverride={toggleExpandOverride}
|
||||
teamSessionIds={teamSessionIds}
|
||||
currentLeadSessionId={currentLeadSessionId}
|
||||
isTeamAlive={isTeamAlive}
|
||||
leadActivity={leadActivity}
|
||||
leadContextUpdatedAt={leadContextUpdatedAt}
|
||||
teamNames={teamNames}
|
||||
teamColorByName={teamColorByName}
|
||||
onTeamClick={openTeamTab}
|
||||
onMemberClick={onMemberClick}
|
||||
onCreateTaskFromMessage={onCreateTaskFromMessage}
|
||||
onReplyToMessage={onReplyToMessage}
|
||||
onMessageVisible={handleMessageVisible}
|
||||
onRestartTeam={onRestartTeam}
|
||||
onTaskIdClick={onTaskIdClick}
|
||||
onExpandItem={handleExpandItem}
|
||||
onExpandContent={handleExpandContent}
|
||||
/>
|
||||
{hasMore && (
|
||||
<div className="flex justify-center py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-text-muted"
|
||||
disabled={messagesLoading}
|
||||
onClick={() => void loadOlderMessages()}
|
||||
>
|
||||
{messagesLoading ? 'Loading...' : 'Load older messages'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MessageExpandDialog
|
||||
expandedItem={expandedItem}
|
||||
open={expandedItemKey !== null}
|
||||
onOpenChange={handleExpandDialogChange}
|
||||
teamName={teamName}
|
||||
members={members}
|
||||
onCreateTaskFromMessage={onCreateTaskFromMessage}
|
||||
onReplyToMessage={onReplyToMessage}
|
||||
onMemberClick={onMemberClick}
|
||||
onTaskIdClick={onTaskIdClick}
|
||||
onRestartTeam={onRestartTeam}
|
||||
teamNames={teamNames}
|
||||
teamColorByName={teamColorByName}
|
||||
onTeamClick={openTeamTab}
|
||||
/>
|
||||
</Sheet.Content>
|
||||
)}
|
||||
</Sheet.Container>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Inline mode (wrapped in CollapsibleTeamSection) ----
|
||||
return (
|
||||
<CollapsibleTeamSection
|
||||
|
|
@ -810,22 +1213,42 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
) : undefined
|
||||
}
|
||||
headerExtra={
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="pointer-events-auto size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTogglePosition();
|
||||
}}
|
||||
>
|
||||
<PanelLeft size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Move to sidebar</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="pointer-events-auto size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
moveToBottomSheet();
|
||||
}}
|
||||
aria-label="Move messages to bottom sheet"
|
||||
>
|
||||
<PanelBottom size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Move to bottom sheet</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="pointer-events-auto size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
moveToSidebar();
|
||||
}}
|
||||
aria-label="Move messages to sidebar"
|
||||
>
|
||||
<PanelLeft size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Move to sidebar</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
defaultOpen
|
||||
action={<div className="flex items-center gap-2 px-2">{searchAndFilterBar}</div>}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ interface StatusBlockProps {
|
|||
pendingRepliesByMember: Record<string, number>;
|
||||
/** Where the Messages panel is rendered — 'sidebar' hides "In progress" (already visible in MemberList). */
|
||||
position?: 'sidebar' | 'inline';
|
||||
/** Overlay keeps the toggle hovering over the previous section, flow keeps it in normal layout. */
|
||||
layout?: 'overlay' | 'flow';
|
||||
onMemberClick?: (member: ResolvedTeamMember) => void;
|
||||
onTaskClick?: (task: TeamTaskWithKanban) => void;
|
||||
}
|
||||
|
|
@ -31,6 +33,7 @@ export const StatusBlock = ({
|
|||
messages,
|
||||
pendingRepliesByMember,
|
||||
position,
|
||||
layout = 'overlay',
|
||||
onMemberClick,
|
||||
onTaskClick,
|
||||
}: StatusBlockProps): React.JSX.Element | null => {
|
||||
|
|
@ -68,24 +71,32 @@ export const StatusBlock = ({
|
|||
|
||||
if (!hasItems) return null;
|
||||
|
||||
const toggleButton = (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => setCollapsed((prev) => !prev)}
|
||||
aria-label={collapsed ? 'Expand status' : 'Collapse status'}
|
||||
>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={`shrink-0 transition-transform duration-150 ${collapsed ? '' : 'rotate-90'}`}
|
||||
/>
|
||||
Status
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative h-0">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute -top-[19px] right-0 z-10 flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => setCollapsed((prev) => !prev)}
|
||||
aria-label={collapsed ? 'Expand status' : 'Collapse status'}
|
||||
>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={`shrink-0 transition-transform duration-150 ${collapsed ? '' : 'rotate-90'}`}
|
||||
/>
|
||||
Status
|
||||
</button>
|
||||
</div>
|
||||
{layout === 'overlay' ? (
|
||||
<div className="relative h-0">
|
||||
<div className="absolute -top-[19px] right-0 z-10">{toggleButton}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-2 flex justify-end">{toggleButton}</div>
|
||||
)}
|
||||
{!collapsed && (
|
||||
<div className="mt-5">
|
||||
<div className={layout === 'overlay' ? 'mt-5' : ''}>
|
||||
<PendingRepliesBlock
|
||||
members={members}
|
||||
pendingRepliesByMember={pendingRepliesByMember}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ export interface TeamMessagesSidebarUiState {
|
|||
messagesFilter: MessagesFilterState;
|
||||
messagesFilterOpen: boolean;
|
||||
messagesCollapsed: boolean;
|
||||
sidebarSearchVisible: boolean;
|
||||
messagesSearchBarVisible: boolean;
|
||||
expandedItemKey: string | null;
|
||||
sidebarScrollTop: number;
|
||||
messagesScrollTop: number;
|
||||
bottomSheetSnapIndex: number;
|
||||
}
|
||||
|
||||
export interface TeamClaudeLogsSidebarUiState {
|
||||
|
|
@ -58,9 +59,10 @@ export function createDefaultMessagesSidebarUiState(): TeamMessagesSidebarUiStat
|
|||
},
|
||||
messagesFilterOpen: false,
|
||||
messagesCollapsed: true,
|
||||
sidebarSearchVisible: false,
|
||||
messagesSearchBarVisible: false,
|
||||
expandedItemKey: null,
|
||||
sidebarScrollTop: 0,
|
||||
messagesScrollTop: 0,
|
||||
bottomSheetSnapIndex: 2,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -326,6 +326,12 @@ interface MentionableTextareaProps extends Omit<
|
|||
value: string;
|
||||
onValueChange: (v: string) => void;
|
||||
suggestions: MentionSuggestion[];
|
||||
/** Surface class applied behind the textarea/overlay content. */
|
||||
surfaceClassName?: string;
|
||||
/** Optional decorative treatment for the surface shell. */
|
||||
surfaceDecoration?: 'none' | 'orbit-border';
|
||||
/** Solid color used by the bottom fade behind corner actions. */
|
||||
surfaceFadeColor?: string;
|
||||
hintText?: string;
|
||||
showHint?: boolean;
|
||||
/** Content rendered at the right side of the footer row (e.g. "Saved") */
|
||||
|
|
@ -334,6 +340,8 @@ interface MentionableTextareaProps extends Omit<
|
|||
cornerAction?: React.ReactNode;
|
||||
/** Content rendered in the bottom-left corner inside the textarea (e.g. mode selector) */
|
||||
cornerActionLeft?: React.ReactNode;
|
||||
/** Density of the reserved bottom inset used by corner actions. */
|
||||
cornerActionInset?: 'default' | 'compact';
|
||||
/** Inline code chips to display as badges */
|
||||
chips?: InlineChip[];
|
||||
/** Called when a chip is removed (by X button, backspace, or reconciliation) */
|
||||
|
|
@ -364,11 +372,15 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
value,
|
||||
onValueChange,
|
||||
suggestions,
|
||||
surfaceClassName,
|
||||
surfaceDecoration = 'none',
|
||||
surfaceFadeColor = 'var(--color-surface-raised)',
|
||||
hintText,
|
||||
showHint = true,
|
||||
footerRight,
|
||||
cornerAction,
|
||||
cornerActionLeft,
|
||||
cornerActionInset = 'default',
|
||||
chips = [],
|
||||
onChipRemove,
|
||||
projectPath,
|
||||
|
|
@ -388,8 +400,15 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
) => {
|
||||
const internalRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||
const backdropRef = React.useRef<HTMLDivElement>(null);
|
||||
const surfaceShellRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [scrollTop, setScrollTop] = React.useState(0);
|
||||
const { isLight } = useTheme();
|
||||
const orbitGlowId = React.useId();
|
||||
const [surfaceShellMetrics, setSurfaceShellMetrics] = React.useState(() => ({
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderRadius: 6,
|
||||
}));
|
||||
|
||||
// --- File search activation ---
|
||||
const enableFiles = !!projectPath;
|
||||
|
|
@ -649,6 +668,39 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
backdrop.style.tabSize = cs.tabSize;
|
||||
}, [value]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (surfaceDecoration !== 'orbit-border') return;
|
||||
const shell = surfaceShellRef.current;
|
||||
if (!shell) return;
|
||||
|
||||
const updateMetrics = () => {
|
||||
const rect = shell.getBoundingClientRect();
|
||||
const computedStyle = window.getComputedStyle(shell);
|
||||
const borderRadius = Number.parseFloat(computedStyle.borderTopLeftRadius) || 6;
|
||||
setSurfaceShellMetrics((prev) => {
|
||||
if (
|
||||
Math.abs(prev.width - rect.width) < 0.5 &&
|
||||
Math.abs(prev.height - rect.height) < 0.5 &&
|
||||
Math.abs(prev.borderRadius - borderRadius) < 0.5
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
borderRadius,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
updateMetrics();
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateMetrics();
|
||||
});
|
||||
resizeObserver.observe(shell);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [surfaceDecoration]);
|
||||
|
||||
// --- Overlay activation ---
|
||||
const hasOverlay =
|
||||
value.includes('http://') ||
|
||||
|
|
@ -1043,17 +1095,99 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
enableTaskSearch ||
|
||||
commandSuggestions.length > 0);
|
||||
const showFooter = showHintRow || footerRight;
|
||||
const hasCornerActions = Boolean(cornerAction || cornerActionLeft);
|
||||
const cornerInsetClass = cornerActionInset === 'compact' ? 'pb-10' : 'pb-12';
|
||||
const cornerFadeHeight = cornerActionInset === 'compact' ? 40 : 48;
|
||||
const cornerActionOffsetClass = cornerActionInset === 'compact' ? 'bottom-1.5' : 'bottom-2';
|
||||
const orbitTrackWidth = 1;
|
||||
const orbitStrokeWidth = 1.35;
|
||||
const orbitGlowWidth = 3;
|
||||
const orbitInset = orbitTrackWidth / 2;
|
||||
const orbitWidth = Math.max(surfaceShellMetrics.width - orbitTrackWidth, 0);
|
||||
const orbitHeight = Math.max(surfaceShellMetrics.height - orbitTrackWidth, 0);
|
||||
const orbitRadius = Math.max(surfaceShellMetrics.borderRadius - orbitInset, 0);
|
||||
const orbitRight = orbitInset + orbitWidth;
|
||||
const orbitBottom = orbitInset + orbitHeight;
|
||||
const orbitMidX = orbitInset + orbitWidth / 2;
|
||||
const orbitPathData =
|
||||
orbitWidth > 0 && orbitHeight > 0
|
||||
? [
|
||||
`M ${orbitMidX} ${orbitInset}`,
|
||||
`H ${orbitRight - orbitRadius}`,
|
||||
`A ${orbitRadius} ${orbitRadius} 0 0 1 ${orbitRight} ${orbitInset + orbitRadius}`,
|
||||
`V ${orbitBottom - orbitRadius}`,
|
||||
`A ${orbitRadius} ${orbitRadius} 0 0 1 ${orbitRight - orbitRadius} ${orbitBottom}`,
|
||||
`H ${orbitInset + orbitRadius}`,
|
||||
`A ${orbitRadius} ${orbitRadius} 0 0 1 ${orbitInset} ${orbitBottom - orbitRadius}`,
|
||||
`V ${orbitInset + orbitRadius}`,
|
||||
`A ${orbitRadius} ${orbitRadius} 0 0 1 ${orbitInset + orbitRadius} ${orbitInset}`,
|
||||
`H ${orbitMidX}`,
|
||||
'Z',
|
||||
].join(' ')
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Inner wrapper for textarea + backdrop overlay */}
|
||||
<div className="relative">
|
||||
<div ref={surfaceShellRef} className={cn('relative rounded-md', surfaceClassName)}>
|
||||
{surfaceDecoration === 'orbit-border' &&
|
||||
surfaceShellMetrics.width > 0 &&
|
||||
surfaceShellMetrics.height > 0 ? (
|
||||
<svg
|
||||
className="message-composer-orbit-svg pointer-events-none absolute inset-0 z-[1] h-full w-full"
|
||||
viewBox={`0 0 ${surfaceShellMetrics.width} ${surfaceShellMetrics.height}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<filter id={orbitGlowId} x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="1.35" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
className="message-composer-orbit-track"
|
||||
d={orbitPathData}
|
||||
pathLength="100"
|
||||
fill="none"
|
||||
strokeWidth={orbitTrackWidth}
|
||||
/>
|
||||
<path
|
||||
className="message-composer-orbit-glow"
|
||||
d={orbitPathData}
|
||||
pathLength="100"
|
||||
fill="none"
|
||||
filter={`url(#${orbitGlowId})`}
|
||||
strokeWidth={orbitGlowWidth}
|
||||
/>
|
||||
<path
|
||||
className="message-composer-orbit-glow message-composer-orbit-glow-secondary"
|
||||
d={orbitPathData}
|
||||
pathLength="100"
|
||||
fill="none"
|
||||
filter={`url(#${orbitGlowId})`}
|
||||
strokeWidth={orbitGlowWidth}
|
||||
/>
|
||||
<path
|
||||
className="message-composer-orbit-path"
|
||||
d={orbitPathData}
|
||||
pathLength="100"
|
||||
fill="none"
|
||||
strokeWidth={orbitStrokeWidth}
|
||||
/>
|
||||
<path
|
||||
className="message-composer-orbit-path message-composer-orbit-path-secondary"
|
||||
d={orbitPathData}
|
||||
pathLength="100"
|
||||
fill="none"
|
||||
strokeWidth={orbitStrokeWidth}
|
||||
/>
|
||||
</svg>
|
||||
) : null}
|
||||
{hasOverlay ? (
|
||||
<div
|
||||
ref={backdropRef}
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 z-0 overflow-hidden rounded-md border border-transparent px-3 py-2 text-sm text-[var(--color-text)]',
|
||||
(cornerAction || cornerActionLeft) && 'pb-12'
|
||||
hasCornerActions && cornerInsetClass
|
||||
)}
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
|
|
@ -1204,7 +1338,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
onKeyDown={composedHandleKeyDown}
|
||||
onSelect={composedHandleSelect}
|
||||
{...textareaProps}
|
||||
className={cn(className, (cornerAction || cornerActionLeft) && 'pb-12')}
|
||||
className={cn(className, hasCornerActions && cornerInsetClass)}
|
||||
onScroll={handleScroll}
|
||||
style={textareaStyle}
|
||||
/>
|
||||
|
|
@ -1220,25 +1354,34 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
) : null}
|
||||
|
||||
{/* Gradient fade overlay before corner action buttons */}
|
||||
{cornerAction || cornerActionLeft ? (
|
||||
{hasCornerActions ? (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 z-[15] rounded-b-md"
|
||||
style={{
|
||||
height: 48,
|
||||
background:
|
||||
'linear-gradient(to bottom, transparent 0%, var(--color-surface-raised) 75%)',
|
||||
height: cornerFadeHeight,
|
||||
background: `linear-gradient(to bottom, transparent 0%, ${surfaceFadeColor} 75%)`,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{cornerAction ? (
|
||||
<div className="pointer-events-none absolute bottom-2 right-2 z-20 flex items-end justify-end">
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute right-2 z-20 flex items-end justify-end',
|
||||
cornerActionOffsetClass
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-auto">{cornerAction}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{cornerActionLeft ? (
|
||||
<div className="pointer-events-none absolute bottom-2 left-2 z-20 flex items-end justify-start">
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute left-2 z-20 flex items-end justify-start',
|
||||
cornerActionOffsetClass
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-auto">{cornerActionLeft}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -1323,3 +1323,104 @@ body.theme-transitioning {
|
|||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes composer-orbit-border {
|
||||
0% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: -100;
|
||||
}
|
||||
}
|
||||
|
||||
.message-composer-orbit-surface {
|
||||
isolation: isolate;
|
||||
--message-composer-orbit-stroke: rgba(129, 140, 248, 0.92);
|
||||
--message-composer-orbit-glow-stroke: rgba(96, 165, 250, 0.62);
|
||||
}
|
||||
|
||||
.message-composer-shell {
|
||||
background-color: var(--color-surface-raised);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.18),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.message-composer-orbit-svg {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.message-composer-orbit-track,
|
||||
.message-composer-orbit-path,
|
||||
.message-composer-orbit-glow {
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.message-composer-orbit-track {
|
||||
stroke: var(--color-border-emphasis);
|
||||
opacity: 1;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.message-composer-orbit-path,
|
||||
.message-composer-orbit-glow {
|
||||
stroke-dasharray: 9 91;
|
||||
animation: composer-orbit-border 19s linear infinite;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
will-change: stroke-dashoffset;
|
||||
}
|
||||
|
||||
.message-composer-orbit-path {
|
||||
stroke-width: 1.35px;
|
||||
stroke: var(--message-composer-orbit-stroke);
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.message-composer-orbit-glow {
|
||||
stroke-width: 3px;
|
||||
stroke: var(--message-composer-orbit-glow-stroke);
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.message-composer-orbit-path-secondary,
|
||||
.message-composer-orbit-glow-secondary {
|
||||
animation-delay: -9.5s;
|
||||
}
|
||||
|
||||
.message-composer-orbit-surface:focus-within .message-composer-orbit-path,
|
||||
.message-composer-orbit-surface:focus-within .message-composer-orbit-glow {
|
||||
animation-duration: 14.5s;
|
||||
}
|
||||
|
||||
.message-composer-orbit-surface:focus-within .message-composer-orbit-path {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.message-composer-orbit-surface:focus-within .message-composer-orbit-glow {
|
||||
opacity: 0.36;
|
||||
}
|
||||
|
||||
:root.light .message-composer-shell {
|
||||
background-color: #dfdbd4;
|
||||
box-shadow:
|
||||
0 10px 24px rgba(113, 104, 92, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
:root.light .message-composer-orbit-surface {
|
||||
--message-composer-orbit-stroke: rgba(79, 70, 229, 0.76);
|
||||
--message-composer-orbit-glow-stroke: rgba(96, 165, 250, 0.46);
|
||||
}
|
||||
|
||||
:root.light .message-composer-orbit-track {
|
||||
stroke: rgba(138, 130, 118, 0.2);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.message-composer-orbit-path,
|
||||
.message-composer-orbit-glow {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
|||
|
||||
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
||||
|
||||
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
|
||||
|
||||
const logger = createLogger('teamSlice');
|
||||
|
||||
const TEAM_GET_DATA_TIMEOUT_MS = 30_000;
|
||||
|
|
@ -40,9 +42,9 @@ const teamRefreshBurstDiagnostics = new Map<
|
|||
{ windowStartedAt: number; count: number; lastWarnAt: number }
|
||||
>();
|
||||
const memberSpawnUiEqualLastWarnAtByTeam = new Map<string, number>();
|
||||
type RefreshTeamDataOptions = {
|
||||
interface RefreshTeamDataOptions {
|
||||
withDedup?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function isTeamDataRefreshPending(teamName: string): boolean {
|
||||
return (
|
||||
|
|
@ -487,9 +489,9 @@ import type {
|
|||
KanbanColumnId,
|
||||
LeadActivityState,
|
||||
LeadContextUsage,
|
||||
PersistedTeamLaunchSummary,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
MemberSpawnStatusEntry,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
PersistedTeamLaunchSummary,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
TaskChangePresenceState,
|
||||
|
|
@ -852,11 +854,7 @@ function preserveKnownTaskChangePresence(
|
|||
}
|
||||
|
||||
const previousTask = prevTaskById.get(task.id);
|
||||
if (
|
||||
!previousTask ||
|
||||
!previousTask.changePresence ||
|
||||
previousTask.changePresence === 'unknown'
|
||||
) {
|
||||
if (!previousTask?.changePresence || previousTask.changePresence === 'unknown') {
|
||||
return task;
|
||||
}
|
||||
|
||||
|
|
@ -1111,10 +1109,10 @@ export interface TeamSlice {
|
|||
) => Promise<void>;
|
||||
|
||||
// Messages panel UI state
|
||||
messagesPanelMode: 'sidebar' | 'inline';
|
||||
messagesPanelMode: TeamMessagesPanelMode;
|
||||
messagesPanelWidth: number;
|
||||
sidebarLogsHeight: number;
|
||||
setMessagesPanelMode: (mode: 'sidebar' | 'inline') => void;
|
||||
setMessagesPanelMode: (mode: TeamMessagesPanelMode) => void;
|
||||
setMessagesPanelWidth: (width: number) => void;
|
||||
setSidebarLogsHeight: (height: number) => void;
|
||||
}
|
||||
|
|
@ -1395,7 +1393,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
messagesPanelMode: 'sidebar' as const,
|
||||
messagesPanelWidth: 340,
|
||||
sidebarLogsHeight: 213,
|
||||
setMessagesPanelMode: (mode: 'sidebar' | 'inline') => set({ messagesPanelMode: mode }),
|
||||
setMessagesPanelMode: (mode: TeamMessagesPanelMode) => set({ messagesPanelMode: mode }),
|
||||
setMessagesPanelWidth: (width: number) => set({ messagesPanelWidth: width }),
|
||||
setSidebarLogsHeight: (height: number) => set({ sidebarLogsHeight: height }),
|
||||
|
||||
|
|
|
|||
1
src/renderer/types/teamMessagesPanelMode.ts
Normal file
1
src/renderer/types/teamMessagesPanelMode.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type TeamMessagesPanelMode = 'sidebar' | 'inline' | 'bottom-sheet';
|
||||
|
|
@ -60,7 +60,8 @@ vi.mock('@renderer/components/ui/button', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) =>
|
||||
|
|
@ -101,6 +102,21 @@ vi.mock('@renderer/components/team/activity/MessageExpandDialog', () => ({
|
|||
MessageExpandDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('react-modal-sheet', () => ({
|
||||
Sheet: Object.assign(
|
||||
({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
|
||||
{
|
||||
Container: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
Header: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
DragIndicator: () => React.createElement('div', null, 'drag-indicator'),
|
||||
Content: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
}
|
||||
),
|
||||
}));
|
||||
|
||||
import { MessagesPanel } from '@renderer/components/team/messages/MessagesPanel';
|
||||
|
||||
function makeMessage(overrides: Partial<InboxMessage> = {}): InboxMessage {
|
||||
|
|
@ -163,7 +179,7 @@ describe('MessagesPanel idle summary invariants', () => {
|
|||
React.createElement(MessagesPanel, {
|
||||
teamName: 'atlas-hq',
|
||||
position: 'sidebar',
|
||||
onTogglePosition: vi.fn(),
|
||||
onPositionChange: vi.fn(),
|
||||
members: [],
|
||||
tasks: [],
|
||||
messages,
|
||||
|
|
@ -214,7 +230,7 @@ describe('MessagesPanel idle summary invariants', () => {
|
|||
React.createElement(MessagesPanel, {
|
||||
teamName: 'atlas-hq',
|
||||
position: 'sidebar',
|
||||
onTogglePosition: vi.fn(),
|
||||
onPositionChange: vi.fn(),
|
||||
members: [],
|
||||
tasks: [],
|
||||
messages,
|
||||
|
|
@ -234,4 +250,41 @@ describe('MessagesPanel idle summary invariants', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the bottom-sheet composer before the status block so input stays pinned near the header', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
const mountPoint = document.createElement('div');
|
||||
host.appendChild(mountPoint);
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MessagesPanel, {
|
||||
teamName: 'atlas-hq',
|
||||
position: 'bottom-sheet',
|
||||
mountPoint,
|
||||
onPositionChange: vi.fn(),
|
||||
members: [],
|
||||
tasks: [],
|
||||
messages: [makeMessage()],
|
||||
timeWindow: null,
|
||||
teamSessionIds: new Set<string>(),
|
||||
pendingRepliesByMember: {},
|
||||
onPendingReplyChange: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const text = host.textContent ?? '';
|
||||
expect(text.indexOf('composer')).toBeGreaterThan(-1);
|
||||
expect(text.indexOf('status-block')).toBeGreaterThan(text.indexOf('composer'));
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue