Skip to content

Commit ad89dd6

Browse files
committed
Refactor layout to macOS-native title bar, vertical nav rail, and unified toolbar with mode switcher and export actions
- Add macOS-specific window chrome with hidden titleBarStyle and custom traffic light positioning in main/index.ts - Replace AppSidebar/TopBar with TitleBar, NavRail, and SessionListPanel components for vertical navigation architecture - Add NavRail component with icon-based navigation, version badge, and settings button - Add TitleBar component with platform-specific drag regions
1 parent 5969631 commit ad89dd6

12 files changed

Lines changed: 1364 additions & 137 deletions

File tree

‎src/main/index.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,9 @@ function createWindow(): void {
165165

166166
show: false,
167167

168-
frame: false,
168+
...(process.platform === 'darwin'
169+
? { titleBarStyle: 'hidden' as const, trafficLightPosition: { x: 12, y: 12 } }
170+
: { frame: false }),
169171

170172
autoHideMenuBar: true,
171173

‎src/renderer/src/components/chat/MessageList.tsx‎

Lines changed: 4 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,12 @@ import { useChatStore } from '@renderer/stores/chat-store'
44
import { useUIStore } from '@renderer/stores/ui-store'
55
import { useAgentStore } from '@renderer/stores/agent-store'
66
import { MessageItem } from './MessageItem'
7-
import { MessageSquare, Briefcase, Code2, RefreshCw, ArrowDown, ClipboardCopy, Check, ImageDown, Loader2 } from 'lucide-react'
7+
import { MessageSquare, Briefcase, Code2, RefreshCw, ArrowDown, Loader2 } from 'lucide-react'
88
import { Button } from '@renderer/components/ui/button'
9-
import { sessionToMarkdown } from '@renderer/lib/utils/export-chat'
9+
1010
import type { ContentBlock, ToolResultContent, UnifiedMessage } from '@renderer/lib/api/types'
1111
import type { ToolCallState } from '@renderer/lib/agent/types'
12-
import { toast } from 'sonner'
13-
import appIconUrl from '../../../../../resources/icon.png'
12+
1413

1514
const modeHints = {
1615
chat: {
@@ -170,8 +169,6 @@ export function MessageList({ onRetry, onEditUserMessage }: MessageListProps): R
170169
return result
171170
}, [visibleRenderableMeta, messages])
172171
const hiddenMessageCount = Math.max(0, renderableMeta.items.length - visibleRenderableMeta.length)
173-
const [copiedAll, setCopiedAll] = React.useState(false)
174-
const [exporting, setExporting] = React.useState(false)
175172
const contentRef = React.useRef<HTMLDivElement>(null)
176173
const hasAssistantMessages = renderableMeta.hasAssistantMessages
177174

@@ -349,110 +346,10 @@ export function MessageList({ onRetry, onEditUserMessage }: MessageListProps): R
349346
)
350347
}
351348

352-
const handleCopyAll = (): void => {
353-
if (!activeSession) return
354-
const md = sessionToMarkdown(activeSession)
355-
navigator.clipboard.writeText(md)
356-
setCopiedAll(true)
357-
setTimeout(() => setCopiedAll(false), 2000)
358-
}
359-
360-
const handleExportImage = async (): Promise<void> => {
361-
const node = contentRef.current
362-
if (!node || !activeSession) return
363-
setExporting(true)
364-
365-
// Convert app icon to base64 data URL for embedding in the footer
366-
let iconDataUrl = ''
367-
try {
368-
const resp = await fetch(appIconUrl)
369-
const blob = await resp.blob()
370-
iconDataUrl = await new Promise<string>((resolve, reject) => {
371-
const reader = new FileReader()
372-
reader.onloadend = () => resolve(reader.result as string)
373-
reader.onerror = reject
374-
reader.readAsDataURL(blob)
375-
})
376-
} catch {
377-
// Silently skip icon if it fails to load
378-
}
379-
380-
// Build and inject footer DOM element
381-
const footer = document.createElement('div')
382-
footer.setAttribute('data-export-footer', '1')
383-
footer.style.cssText = 'margin-top:24px;padding:20px 0 8px;border-top:1px solid rgba(128,128,128,0.2);display:flex;align-items:center;justify-content:center;gap:14px;'
384-
footer.innerHTML = [
385-
iconDataUrl
386-
? `<img src="${iconDataUrl}" style="width:40px;height:40px;border-radius:8px;flex-shrink:0;" />`
387-
: '',
388-
'<div style="display:flex;flex-direction:column;justify-content:center;gap:2px;">',
389-
' <span style="font-weight:600;font-size:14px;color:rgba(128,128,128,0.75);">OpenCowork</span>',
390-
' <span style="font-size:11px;color:rgba(128,128,128,0.5);">AI-Powered Collaborative Development Platform</span>',
391-
' <span style="font-size:11px;color:rgba(128,128,128,0.45);">github.com/AIDotNet/OpenCowork</span>',
392-
'</div>',
393-
].join('\n')
394-
node.appendChild(footer)
395-
396-
try {
397-
// Wait for browser to layout the footer before capturing
398-
await new Promise((r) => setTimeout(r, 150))
399-
400-
const bgRaw = getComputedStyle(document.documentElement).getPropertyValue('--background').trim()
401-
const bgColor = bgRaw ? `hsl(${bgRaw})` : '#ffffff'
402-
const { toPng } = await import('html-to-image')
403-
const dataUrl = await toPng(node, { backgroundColor: bgColor, pixelRatio: 2 })
404-
405-
const base64 = dataUrl.split(',')[1]
406-
const binary = atob(base64)
407-
const bytes = new Uint8Array(binary.length)
408-
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
409-
const blob = new Blob([bytes], { type: 'image/png' })
410-
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
411-
toast.success(t('messageList.imageCopied'), { description: t('messageList.imageCopiedDesc') })
412-
} catch (err) {
413-
console.error('Export image failed:', err)
414-
toast.error(t('messageList.exportImageFailed'), { description: String(err) })
415-
} finally {
416-
if (node.contains(footer)) node.removeChild(footer)
417-
setExporting(false)
418-
}
419-
}
420-
421349
return (
422350
<div className="relative flex-1">
423-
{/* Floating action bar — visible by default when conversation has content */}
424-
{messages.length > 0 && (
425-
<div className="absolute top-2 right-4 z-10 flex items-center gap-0.5 rounded-lg border bg-background/80 backdrop-blur-sm shadow-sm px-0.5 py-0.5">
426-
<button
427-
className="group/btn flex h-6 items-center gap-1 rounded-md px-1.5 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-all duration-200 disabled:opacity-50"
428-
onClick={handleExportImage}
429-
disabled={exporting || !!streamingMessageId}
430-
>
431-
{exporting ? <Loader2 className="size-3.5 shrink-0 animate-spin" /> : <ImageDown className="size-3.5 shrink-0" />}
432-
<span
433-
className="max-w-0 overflow-hidden pl-0 text-[10px] opacity-0 whitespace-nowrap group-hover/btn:max-w-[140px] group-hover/btn:pl-1 group-hover/btn:opacity-100"
434-
style={{ transition: 'max-width 220ms cubic-bezier(0.4, 0, 0.2, 1), opacity 160ms ease, padding 180ms ease' }}
435-
>
436-
{exporting ? t('messageList.exporting') : t('messageList.exportImage')}
437-
</span>
438-
</button>
439-
<button
440-
className="group/btn flex h-6 items-center gap-1 rounded-md px-1.5 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-all duration-200 disabled:opacity-50"
441-
onClick={handleCopyAll}
442-
disabled={!!streamingMessageId}
443-
>
444-
{copiedAll ? <Check className="size-3.5 shrink-0" /> : <ClipboardCopy className="size-3.5 shrink-0" />}
445-
<span
446-
className="max-w-0 overflow-hidden pl-0 text-[10px] opacity-0 whitespace-nowrap group-hover/btn:max-w-[140px] group-hover/btn:pl-1 group-hover/btn:opacity-100"
447-
style={{ transition: 'max-width 220ms cubic-bezier(0.4, 0, 0.2, 1), opacity 160ms ease, padding 180ms ease' }}
448-
>
449-
{copiedAll ? t('messageList.copied') : t('messageList.copyAll')}
450-
</span>
451-
</button>
452-
</div>
453-
)}
454351
<div ref={scrollContainerRef} className="absolute inset-0 overflow-y-auto">
455-
<div ref={contentRef} className="mx-auto max-w-3xl space-y-6 p-4 overflow-hidden">
352+
<div ref={contentRef} data-message-content className="mx-auto max-w-3xl space-y-6 p-4 overflow-hidden">
456353
{hiddenMessageCount > 0 && (
457354
<div className="flex justify-center">
458355
<button

‎src/renderer/src/components/cowork/StepsPanel.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,10 @@ export function StepsPanel(): React.JSX.Element {
6262
}
6363

6464
return (
65-
<div className="space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
65+
<div className="space-y-2 max-h-[calc(100vh-200px)] overflow-y-auto">
6666
{/* Plan-linked tasks */}
6767
{plan && planTasks.length > 0 && (
68-
<div className="space-y-3">
68+
<div className="space-y-2">
6969
<div className="flex items-center gap-2">
7070
<div className="flex items-center justify-center rounded-md bg-violet-500/10 p-1">
7171
<ClipboardList className="size-3.5 text-violet-500" />
@@ -119,7 +119,7 @@ export function StepsPanel(): React.JSX.Element {
119119
function TodoList({ todos, progress, isRunning }: { todos: TaskItem[]; progress: { total: number; completed: number; percentage: number }; isRunning: boolean }): React.JSX.Element {
120120
const { t } = useTranslation('cowork')
121121
return (
122-
<div className="space-y-3">
122+
<div className="space-y-2">
123123
{/* Progress Bar */}
124124
{todos.length > 0 && (
125125
<>
@@ -207,7 +207,7 @@ function TeamTaskList({
207207
const percentage = tasks.length === 0 ? 0 : Math.round((completedCount / tasks.length) * 100)
208208

209209
return (
210-
<div className="space-y-3">
210+
<div className="space-y-2">
211211
{/* Header */}
212212
<div className="flex items-center gap-2">
213213
<div className="flex items-center justify-center rounded-md bg-cyan-500/10 p-1">

0 commit comments

Comments
 (0)