feat(messages): add bottom sheet panel mode

This commit is contained in:
777genius 2026-04-12 22:17:18 +03:00
parent 02d516cb4e
commit cc45549716
13 changed files with 1753 additions and 896 deletions

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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>}

View file

@ -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}

View file

@ -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,
};
}

View file

@ -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}

View file

@ -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;
}
}

View file

@ -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 }),

View file

@ -0,0 +1 @@
export type TeamMessagesPanelMode = 'sidebar' | 'inline' | 'bottom-sheet';

View file

@ -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();
});
});
});