feat: unify Settings/Scheduled as tab pages, improve new session UX

- Settings and Scheduled Tasks now open as special tabs in the TabBar,
  matching IDE conventions (closeable, icon-prefixed, persistent)
- New Session directly creates a session with the current workDir
- ChatInput shows DirectoryPicker before first message, locks after
- Remove connection status from StatusBar (unnecessary noise)
- ContentRouter routes by tab type instead of activeView
- Settings page no longer has a back-button header

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-09 01:19:02 +08:00
parent f2fa6fc56d
commit 7e2c8803f7
11 changed files with 121 additions and 135 deletions

View File

@ -38,7 +38,7 @@ describe('Content-only pages render without errors', () => {
it('ActiveSession renders with chat components', () => {
const SESSION_ID = 'test-active-session'
useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', status: 'idle' }], activeTabId: SESSION_ID })
useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID })
useChatStore.setState({
sessions: {
[SESSION_ID]: {
@ -71,7 +71,7 @@ describe('Content-only pages render without errors', () => {
})
it('ActiveSession shows a single primary action button while a turn is active', () => {
useTabStore.setState({ activeTabId: 'active-tab', tabs: [{ sessionId: 'active-tab', title: 'Test', status: 'idle' }] })
useTabStore.setState({ activeTabId: 'active-tab', tabs: [{ sessionId: 'active-tab', title: 'Test', type: 'session' as const, status: 'idle' }] })
useChatStore.setState({
sessions: {
'active-tab': {

View File

@ -9,6 +9,7 @@ import { ModelSelector } from '../controls/ModelSelector'
import type { AttachmentRef } from '../../types/chat'
import { AttachmentGallery } from './AttachmentGallery'
import { ProjectContextChip } from '../shared/ProjectContextChip'
import { DirectoryPicker } from '../shared/DirectoryPicker'
import { FileSearchMenu, type FileSearchMenuHandle } from './FileSearchMenu'
import {
FALLBACK_SLASH_COMMANDS,
@ -49,9 +50,10 @@ export function ChatInput() {
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
const chatState = sessionState?.chatState ?? 'idle'
const slashCommands = sessionState?.slashCommands ?? []
const activeSessionId = useSessionStore((state) => state.activeSessionId)
const activeSession = useSessionStore((state) => state.sessions.find((session) => session.id === state.activeSessionId) ?? null)
const activeSession = useSessionStore((state) => activeTabId ? state.sessions.find((session) => session.id === activeTabId) ?? null : null)
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null)
const messages = useChatStore((s) => activeTabId ? s.sessions[activeTabId]?.messages ?? [] : [])
const hasMessages = messages.length > 0
const isActive = chatState !== 'idle'
const isWorkspaceMissing = activeSession?.workDirExists === false
@ -62,12 +64,12 @@ export function ChatInput() {
}, [isActive])
useEffect(() => {
if (!activeSessionId) {
if (!activeTabId) {
setGitInfo(null)
return
}
sessionsApi.getGitInfo(activeSessionId).then(setGitInfo).catch(() => setGitInfo(null))
}, [activeSessionId])
sessionsApi.getGitInfo(activeTabId).then(setGitInfo).catch(() => setGitInfo(null))
}, [activeTabId])
useEffect(() => {
const el = textareaRef.current
@ -518,11 +520,30 @@ export function ChatInput() {
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} />
<div className="mt-3 px-1">
<ProjectContextChip
workDir={gitInfo?.workDir || activeSession?.workDir}
repoName={gitInfo?.repoName || null}
branch={gitInfo?.branch || null}
/>
{hasMessages ? (
<ProjectContextChip
workDir={gitInfo?.workDir || activeSession?.workDir}
repoName={gitInfo?.repoName || null}
branch={gitInfo?.branch || null}
/>
) : (
<DirectoryPicker
value={gitInfo?.workDir || activeSession?.workDir || ''}
onChange={async (newWorkDir) => {
if (!activeTabId) return
// Recreate session with new workDir
const { deleteSession, createSession } = useSessionStore.getState()
const { closeTab, openTab } = useTabStore.getState()
const { disconnectSession, connectToSession } = useChatStore.getState()
disconnectSession(activeTabId)
closeTab(activeTabId)
await deleteSession(activeTabId)
const newId = await createSession(newWorkDir)
openTab(newId, t('sidebar.newSession'))
connectToSession(newId)
}}
/>
)}
</div>
</div>
</div>

View File

@ -30,7 +30,7 @@ function makeSessionState(overrides: Partial<PerSessionState> = {}): PerSessionS
describe('MessageList nested tool calls', () => {
beforeEach(() => {
useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', status: 'idle' }] })
useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session' as const, status: 'idle' }] })
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState() } })
})

View File

@ -8,7 +8,7 @@ import { useTabStore } from '../../stores/tabStore'
describe('chat blocks', () => {
beforeEach(() => {
useTabStore.setState({ activeTabId: 'active-tab', tabs: [{ sessionId: 'active-tab', title: 'Test', status: 'idle' }] })
useTabStore.setState({ activeTabId: 'active-tab', tabs: [{ sessionId: 'active-tab', title: 'Test', type: 'session' as const, status: 'idle' }] })
useChatStore.setState({ sessions: {} })
})

View File

@ -6,6 +6,7 @@ import { useSettingsStore } from '../../stores/settingsStore'
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
import { initializeDesktopServerUrl } from '../../lib/desktopRuntime'
import { TabBar } from './TabBar'
import { StatusBar } from './StatusBar'
import { useTabStore } from '../../stores/tabStore'
import { useChatStore } from '../../stores/chatStore'
@ -103,6 +104,7 @@ export function AppShell() {
<main id="content-area" className={`flex-1 flex flex-col overflow-hidden relative ${isTauri ? 'pt-[38px]' : ''}`}>
<TabBar />
<ContentRouter />
<StatusBar />
</main>
<ToastContainer />
</div>

View File

@ -1,4 +1,3 @@
import { useUIStore } from '../../stores/uiStore'
import { useTabStore } from '../../stores/tabStore'
import { useTeamStore } from '../../stores/teamStore'
import { EmptySession } from '../../pages/EmptySession'
@ -8,62 +7,28 @@ import { Settings } from '../../pages/Settings'
import { AgentTranscript } from '../../pages/AgentTranscript'
export function ContentRouter() {
const activeView = useUIStore((s) => s.activeView)
const activeTabId = useTabStore((s) => s.activeTabId)
const activeTab = useTabStore((s) => s.tabs.find((t) => t.sessionId === s.activeTabId))
const viewingAgentId = useTeamStore((s) => s.viewingAgentId)
if (activeView === 'settings') {
return <Settings />
}
if (activeView === 'scheduled') {
return <ScheduledTasks />
}
if (activeView === 'terminal') {
return <TerminalPlaceholder />
}
if (activeView === 'history') {
// If viewing an agent transcript, show that
if (viewingAgentId) {
return <AgentTranscript />
}
return <HistoryPlaceholder />
}
// Code view
if (!activeTabId) {
// No tabs open — show empty session
if (!activeTabId || !activeTab) {
return <EmptySession />
}
// Special tabs
if (activeTab.type === 'settings') {
return <Settings />
}
if (activeTab.type === 'scheduled') {
return <ScheduledTasks />
}
// Session tab — show agent transcript or active session
if (viewingAgentId) {
return <AgentTranscript />
}
return <ActiveSession />
}
function TerminalPlaceholder() {
return (
<div className="flex-1 flex flex-col items-center justify-center bg-[#1e1e1e] text-[#d4d4d4]">
<span className="material-symbols-outlined text-[48px] text-[#555] mb-4">terminal</span>
<h2 className="text-lg font-semibold text-[#999] mb-2">Terminal</h2>
<p className="text-sm text-[#666] max-w-sm text-center">
Integrated terminal coming soon. Use the Code tab to interact with Claude.
</p>
</div>
)
}
function HistoryPlaceholder() {
return (
<div className="flex-1 flex flex-col items-center justify-center bg-[var(--color-surface)]">
<span className="material-symbols-outlined text-[48px] text-[var(--color-outline)] mb-4">history</span>
<h2 className="text-lg font-semibold text-[var(--color-text-secondary)] mb-2">History</h2>
<p className="text-sm text-[var(--color-text-tertiary)] max-w-sm text-center">
View session history and agent transcripts. Select a session from the sidebar to view its history.
</p>
</div>
)
}

View File

@ -1,10 +1,9 @@
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
import { useSessionStore } from '../../stores/sessionStore'
import { useUIStore } from '../../stores/uiStore'
import { useTranslation } from '../../i18n'
import { ProjectFilter } from './ProjectFilter'
import type { SessionListItem } from '../../types/session'
import { useTabStore } from '../../stores/tabStore'
import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore'
import { useChatStore } from '../../stores/chatStore'
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
@ -16,15 +15,13 @@ const TIME_GROUP_ORDER: TimeGroup[] = ['today', 'yesterday', 'last7days', 'last3
export function Sidebar() {
const {
sessions,
activeSessionId,
selectedProjects,
error,
setActiveSession,
fetchSessions,
deleteSession,
renameSession,
} = useSessionStore()
const { activeView, setActiveView } = useUIStore()
const activeTabId = useTabStore((s) => s.activeTabId)
const [searchQuery, setSearchQuery] = useState('')
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null)
const [renamingId, setRenamingId] = useState<string | null>(null)
@ -132,15 +129,29 @@ export function Sidebar() {
{/* Navigation */}
<div className="px-3 pb-3 flex flex-col gap-0.5">
<NavItem
active={activeView === 'code' && !activeSessionId}
onClick={() => { setActiveView('code'); setActiveSession(null) }}
active={false}
onClick={async () => {
try {
// Use current active session's workDir as default for new session
const currentTabId = useTabStore.getState().activeTabId
const currentSession = currentTabId
? useSessionStore.getState().sessions.find((s) => s.id === currentTabId)
: null
const workDir = currentSession?.workDir || undefined
const sessionId = await useSessionStore.getState().createSession(workDir)
useTabStore.getState().openTab(sessionId, t('sidebar.newSession'))
useChatStore.getState().connectToSession(sessionId)
} catch {
// Session creation failed — no tab opened
}
}}
icon={<PlusIcon />}
>
{t('sidebar.newSession')}
</NavItem>
<NavItem
active={activeView === 'scheduled'}
onClick={() => setActiveView('scheduled')}
active={activeTabId === SCHEDULED_TAB_ID}
onClick={() => useTabStore.getState().openTab(SCHEDULED_TAB_ID, t('sidebar.scheduled'), 'scheduled')}
icon={<ClockIcon />}
>
{t('sidebar.scheduled')}
@ -208,22 +219,21 @@ export function Sidebar() {
) : (
<button
onClick={() => {
setActiveView('code')
useTabStore.getState().openTab(session.id, session.title)
useChatStore.getState().connectToSession(session.id)
}}
onContextMenu={(e) => handleContextMenu(e, session.id)}
className={`
w-full flex items-center gap-2 pl-4 pr-3 py-1.5 text-sm text-left rounded-[var(--radius-md)] transition-colors duration-200 group
${session.id === activeSessionId
${session.id === activeTabId
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}
`}
>
<span className="w-1 h-1 rounded-full flex-shrink-0" style={{
backgroundColor: session.id === activeSessionId ? 'var(--color-brand)' : 'var(--color-text-tertiary)',
opacity: session.id === activeSessionId ? 1 : 0.5,
backgroundColor: session.id === activeTabId ? 'var(--color-brand)' : 'var(--color-text-tertiary)',
opacity: session.id === activeTabId ? 1 : 0.5,
}} />
<span className="truncate flex-1">{session.title || 'Untitled'}</span>
{!session.workDirExists && (
@ -249,8 +259,8 @@ export function Sidebar() {
{/* Settings button at bottom */}
<div className="p-3 border-t border-[var(--color-border)]">
<NavItem
active={activeView === 'settings'}
onClick={() => setActiveView('settings')}
active={activeTabId === SETTINGS_TAB_ID}
onClick={() => useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings')}
icon={<span className="material-symbols-outlined text-[18px]">settings</span>}
>
{t('sidebar.settings')}

View File

@ -1,33 +1,13 @@
import { useSettingsStore } from '../../stores/settingsStore'
import { useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useTabStore } from '../../stores/tabStore'
import { useTranslation } from '../../i18n'
export function StatusBar() {
const { currentModel } = useSettingsStore()
const activeTabId = useTabStore((s) => s.activeTabId)
const connectionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId]?.connectionState ?? 'disconnected' : 'disconnected')
const { sessions, activeSessionId } = useSessionStore()
const t = useTranslation()
const sessions = useSessionStore((s) => s.sessions)
const activeSession = sessions.find((s) => s.id === activeSessionId)
const statusColor =
connectionState === 'connected'
? 'bg-[var(--color-success)]'
: connectionState === 'connecting' || connectionState === 'reconnecting'
? 'bg-[var(--color-warning)] animate-pulse-dot'
: 'bg-[var(--color-error)]'
const statusText =
connectionState === 'connected'
? t('status.connected')
: connectionState === 'connecting'
? t('status.connecting')
: connectionState === 'reconnecting'
? t('status.reconnecting')
: t('status.disconnected')
const activeSession = sessions.find((s) => s.id === activeTabId)
const projectName = activeSession?.projectPath
? activeSession.projectPath.split('-').filter(Boolean).pop() || ''
@ -35,18 +15,9 @@ export function StatusBar() {
return (
<div className="h-[var(--statusbar-height)] flex items-center justify-between px-4 border-t border-[var(--color-border)] bg-[var(--color-surface-sidebar)] select-none text-[11px]">
{/* Left: User info + plan */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<div className={`w-1.5 h-1.5 rounded-full ${statusColor}`} />
<span className="text-[var(--color-text-tertiary)]">{statusText}</span>
</div>
{projectName && (
<>
<span className="text-[var(--color-outline)]">·</span>
<span className="text-[var(--color-text-secondary)] font-[var(--font-mono)]">{projectName}</span>
</>
<span className="text-[var(--color-text-secondary)] font-[var(--font-mono)]">{projectName}</span>
)}
</div>

View File

@ -53,6 +53,13 @@ export function TabBar() {
}
const handleClose = (sessionId: string) => {
// Special tabs can always be closed directly
const tab = tabs.find((t) => t.sessionId === sessionId)
if (tab && tab.type !== 'session') {
closeTab(sessionId)
return
}
const sessionState = useChatStore.getState().sessions[sessionId]
const isRunning = sessionState && sessionState.chatState !== 'idle'
@ -198,12 +205,18 @@ function TabItem({ tab, isActive, onClick, onClose, onContextMenu }: {
`}
style={{ width: TAB_WIDTH, maxWidth: TAB_WIDTH }}
>
{tab.status === 'running' && (
{tab.type === 'session' && tab.status === 'running' && (
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)] animate-pulse flex-shrink-0" />
)}
{tab.status === 'error' && (
{tab.type === 'session' && tab.status === 'error' && (
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-error)] flex-shrink-0" />
)}
{tab.type === 'settings' && (
<span className="material-symbols-outlined text-[14px] flex-shrink-0 text-[var(--color-text-tertiary)]">settings</span>
)}
{tab.type === 'scheduled' && (
<span className="material-symbols-outlined text-[14px] flex-shrink-0 text-[var(--color-text-tertiary)]">schedule</span>
)}
<span className={`flex-1 truncate text-xs ${isActive ? 'text-[var(--color-text-primary)] font-medium' : 'text-[var(--color-text-secondary)]'}`}>
{tab.title || 'Untitled'}

View File

@ -1,7 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import { useSettingsStore } from '../stores/settingsStore'
import { useProviderStore } from '../stores/providerStore'
import { useUIStore } from '../stores/uiStore'
import { useTranslation } from '../i18n'
import { Modal } from '../components/shared/Modal'
import { Input } from '../components/shared/Input'
@ -17,22 +16,10 @@ type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters'
export function Settings() {
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
const setActiveView = useUIStore((s) => s.setActiveView)
const t = useTranslation()
return (
<div className="flex-1 flex flex-col overflow-hidden bg-[var(--color-surface)]">
{/* Header */}
<div className="flex items-center gap-3 px-6 py-4 border-b border-[var(--color-border)]">
<button
onClick={() => setActiveView('code')}
className="p-1.5 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors text-[var(--color-text-secondary)]"
>
<span className="material-symbols-outlined text-[20px]">arrow_back</span>
</button>
<h1 className="text-lg font-bold text-[var(--color-text-primary)]">{t('settings.title')}</h1>
</div>
<div className="flex-1 flex overflow-hidden">
{/* Tab navigation */}
<div className="w-48 border-r border-[var(--color-border)] py-3 flex-shrink-0">

View File

@ -3,14 +3,20 @@ import { sessionsApi } from '../api/sessions'
const TAB_STORAGE_KEY = 'cc-haha-open-tabs'
export const SETTINGS_TAB_ID = '__settings__'
export const SCHEDULED_TAB_ID = '__scheduled__'
export type TabType = 'session' | 'settings' | 'scheduled'
export type Tab = {
sessionId: string
title: string
type: TabType
status: 'idle' | 'running' | 'error'
}
type TabPersistence = {
openTabs: Array<{ sessionId: string; title: string }>
openTabs: Array<{ sessionId: string; title: string; type?: TabType }>
activeTabId: string | null
}
@ -18,7 +24,7 @@ type TabStore = {
tabs: Tab[]
activeTabId: string | null
openTab: (sessionId: string, title: string) => void
openTab: (sessionId: string, title: string, type?: TabType) => void
closeTab: (sessionId: string) => void
setActiveTab: (sessionId: string) => void
updateTabTitle: (sessionId: string, title: string) => void
@ -32,14 +38,14 @@ export const useTabStore = create<TabStore>((set, get) => ({
tabs: [],
activeTabId: null,
openTab: (sessionId, title) => {
openTab: (sessionId, title, type = 'session') => {
const { tabs } = get()
const existing = tabs.find((t) => t.sessionId === sessionId)
if (existing) {
set({ activeTabId: sessionId })
} else {
set({
tabs: [...tabs, { sessionId, title, status: 'idle' }],
tabs: [...tabs, { sessionId, title, type, status: 'idle' }],
activeTabId: sessionId,
})
}
@ -89,7 +95,7 @@ export const useTabStore = create<TabStore>((set, get) => ({
saveTabs: () => {
const { tabs, activeTabId } = get()
const data: TabPersistence = {
openTabs: tabs.map((t) => ({ sessionId: t.sessionId, title: t.title })),
openTabs: tabs.map((t) => ({ sessionId: t.sessionId, title: t.title, type: t.type })),
activeTabId,
}
try {
@ -109,12 +115,23 @@ export const useTabStore = create<TabStore>((set, get) => ({
const existingIds = new Set(sessions.map((s) => s.id))
const validTabs: Tab[] = data.openTabs
.filter((t) => existingIds.has(t.sessionId))
.map((t) => ({
sessionId: t.sessionId,
title: sessions.find((s) => s.id === t.sessionId)?.title || t.title,
status: 'idle' as const,
}))
.filter((t) => {
// Special tabs are always valid
if (t.type === 'settings' || t.type === 'scheduled') return true
// Session tabs must exist on server
return existingIds.has(t.sessionId)
})
.map((t) => {
if (t.type === 'settings' || t.type === 'scheduled') {
return { sessionId: t.sessionId, title: t.title, type: t.type, status: 'idle' as const }
}
return {
sessionId: t.sessionId,
title: sessions.find((s) => s.id === t.sessionId)?.title || t.title,
type: 'session' as const,
status: 'idle' as const,
}
})
if (validTabs.length === 0) return