-
-
{connection === 'checking' ? '正在检查' : connection === 'online' ? '连接正常' : '连接异常'}
-
+
+
+
+
+
+
+ {projects.map((project) => )}
+
+ } onClick={() => cycleProject(-1)} />
+ } onClick={() => cycleProject(1)} />
+ } onClick={() => setProjectModalOpen(true)} />
+
+
+ {error ?
setError('')} /> : null}
+ {visibleWorkspaces.length ? : }
+
+
+
+ workspace.tags.map((tag) => tag.name))} onClose={() => setTaskEditor(null)} onSave={async (draft) => {
+ if (!taskEditor) return
+ await updateTask(session, taskEditor.projectId, taskEditor.id, { title: draft.title, description: draft.summary, tag: draft.tag, completed: draft.completed })
+ await loadWorkspaces()
+ setTaskEditor(null)
+ }} />
+ setProjectModalOpen(false)} onSave={async (name) => {
+ const project = await createProject(session, { name, identifier: slugify(name) || `project-${Date.now().toString(36)}`, icon: 'folder', background: '#165DFF', description: '' })
+ setProjectModalOpen(false)
+ await loadWorkspaces()
+ setProjectId(project.id)
+ }} />
+ setProfileModalOpen(false)} onSave={async (input) => {
+ const nextUser = await updateCurrentUser(session, input)
+ setUser(nextUser)
+ persistSession({ ...session, user: nextUser })
+ setProfileModalOpen(false)
+ }} />
)
}
-function ActionModal({ action, workspace, onClose, onProject, onTask, onCron }: {
- action: Action
- workspace: Workspace | null
- onClose: () => void
- onProject: (name: string) => Promise
- onTask: (title: string, tag: string) => Promise
- onCron: (title: string, schedule: string) => Promise
-}) {
- const [title, setTitle] = useState('')
- const [tag, setTag] = useState('')
- const [schedule, setSchedule] = useState('0 9 * * 1-5')
- const [loading, setLoading] = useState(false)
- useEffect(() => { if (action) { setTitle(''); setTag(''); setSchedule('0 9 * * 1-5') } }, [action])
- const labels = action === 'project' ? ['新建项目', '项目名称'] : action === 'task' ? ['新建计划', '计划标题'] : ['新建计划任务', '任务名称']
- return (
-
-
-
- {action === 'task' ? : null}
- {action === 'cron' ? : null}
-
-
-
- )
+function Content({ view, title, workspaces, tasks, onEditTask, onLoadAI, experts }: { view: View; title: string; workspaces: Workspace[]; tasks: TaskWithProject[]; onEditTask: (task: TaskWithProject) => void; onLoadAI: () => void; experts: Expert[] }) {
+ if (view === 'tasks' || view === 'overview') return
+ if (view === 'inbox') return
+ if (view === 'documents') return
+ if (view === 'ai') return
+ return
}
-function readStoredSession(): ApiSession | null {
- try {
- const value = JSON.parse(localStorage.getItem('senlin-mini-session') || 'null') as ApiSession | null
- return value?.baseUrl && value?.token ? value : null
- } catch {
- return null
- }
+function TaskBoard({ title, subtitle, tasks, onEditTask }: { title: string; subtitle: string; tasks: TaskWithProject[]; onEditTask: (task: TaskWithProject) => void }) {
+ const [tag, setTag] = useState('全部')
+ const tags = [...new Set(tasks.map((task) => task.tag).filter(Boolean))]
+ const visible = tag === '全部' ? tasks : tasks.filter((task) => task.tag === tag)
+ const pending = visible.filter((task) => !task.completed)
+ const completed = visible.filter((task) => task.completed)
+ return
+ {subtitle}
{title}{pending.length} 项待办
+ {tags.map((item) => )}
+ 待办任务{pending.length}
{pending.map((task) => onEditTask(task)} />)}{!pending.length && }
+ 已完成{completed.length}
{completed.map((task) => onEditTask(task)} />)}{!completed.length && }
+
}
-function projectMark(project?: Project) {
- if (!project) return
- if (project.icon && !['folder', 'project'].includes(project.icon)) return project.icon
- const chars = Array.from(project.name.trim())
- const chinese = chars.some((char) => /[\u3400-\u9fff]/.test(char))
- return chars.slice(0, chinese ? 2 : 4).join('').toUpperCase()
+function TaskRow({ task, onClick }: { task: TaskWithProject; onClick: () => void }) {
+ return
}
-function errorMessage(error: unknown) {
- if (error instanceof ApiError || error instanceof Error) return error.message
- return '操作失败,请稍后重试'
+function InboxPanel({ title, workspaces }: { title: string; workspaces: Workspace[] }) {
+ const items = workspaces.flatMap((workspace) => workspace.inbox.map((item) => ({ ...item, projectName: workspace.project.name })))
+ return {items.map((item) => {item.title}{item.summary || item.source}
{item.projectName})}{!items.length && }
}
+
+function DocumentPanel({ title, workspaces }: { title: string; workspaces: Workspace[] }) {
+ const documents = workspaces.flatMap((workspace) => workspace.documents.map((item) => ({ ...item, projectName: workspace.project.name })))
+ return {documents.map((item) => {item.name}{item.extension || item.mimeType || item.kind} · 更新于 {formatDate(item.updatedAt)}
{item.projectName})}{!documents.length && }
+}
+
+function AIPanel({ title, workspaces, experts, onLoad }: { title: string; workspaces: Workspace[]; experts: Expert[]; onLoad: () => void }) {
+ useEffect(() => { onLoad() }, [onLoad])
+ const sessions = workspaces.flatMap((workspace) => workspace.aiSessions.map((item) => ({ ...item, projectName: workspace.project.name })))
+ return {experts.slice(0, 6).map((expert) => {expert.emoji}{expert.name})}
{sessions.map((item) => {item.title}{item.summary || 'AI 会话'}
{item.projectName})}{!sessions.length && }
+}
+
+function CronPanel({ title, workspaces }: { title: string; workspaces: Workspace[] }) {
+ const plans = workspaces.flatMap((workspace) => workspace.cronPlans.map((item) => ({ ...item, projectName: workspace.project.name })))
+ return {plans.map((item) => {item.title}{item.schedule} · {item.enabled ? '已启用' : '已停用'}
{item.projectName})}{!plans.length && }
+}
+
+function Panel({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) { return }
+
+function ProfileMenu({ user, onProfile, onLogout }: { user: CurrentUser | null; onProfile: () => void; onLogout: () => void }) {
+ const menu =
+ return
+}
+
+function TaskEditor({ task, tags, onClose, onSave }: { task: TaskWithProject | null; tags: string[]; onClose: () => void; onSave: (draft: { title: string; summary: string; tag: string; completed: boolean }) => Promise }) {
+ const [title, setTitle] = useState(''); const [summary, setSummary] = useState(''); const [tag, setTag] = useState(''); const [completed, setCompleted] = useState(false); const [saving, setSaving] = useState(false)
+ useEffect(() => { if (task) { setTitle(task.title); setSummary(task.summary); setTag(task.tag); setCompleted(task.completed) } }, [task])
+ return { if (!title.trim()) return; setSaving(true); try { await onSave({ title: title.trim(), summary: summary.trim(), tag, completed }) } finally { setSaving(false) } }}>
+}
+
+function ProjectModal({ visible, onClose, onSave }: { visible: boolean; onClose: () => void; onSave: (name: string) => Promise }) {
+ const [name, setName] = useState(''); const [saving, setSaving] = useState(false)
+ return { if (!name.trim()) return; setSaving(true); try { await onSave(name.trim()); setName('') } finally { setSaving(false) } }}>
+}
+
+function ProfileModal({ user, visible, onClose, onSave }: { user: CurrentUser | null; visible: boolean; onClose: () => void; onSave: (input: { displayName: string; currentPassword?: string; newPassword?: string }) => Promise }) {
+ const [displayName, setDisplayName] = useState(user?.displayName ?? ''); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [saving, setSaving] = useState(false)
+ useEffect(() => setDisplayName(user?.displayName ?? ''), [user])
+ return { setSaving(true); try { await onSave({ displayName, currentPassword: currentPassword || undefined, newPassword: newPassword || undefined }); setCurrentPassword(''); setNewPassword('') } finally { setSaving(false) } }}>
+}
+
+function Login({ onLogin }: { onLogin: (session: ApiSession & { user: CurrentUser }) => void }) {
+ const [server, setServer] = useState('http://localhost:9150'); const [email, setEmail] = useState('demo@senlin.ai'); const [password, setPassword] = useState('password123'); const [loading, setLoading] = useState(false); const [error, setError] = useState('')
+ return
+ async function submit() { setLoading(true); setError(''); try { onLogin(await login(server, email.trim(), password)) } catch (requestError) { setError(errorMessage(requestError)) } finally { setLoading(false) } }
+}
+
+function readStoredSession(): SavedSession | null { try { const value = JSON.parse(localStorage.getItem(SESSION_KEY) || localStorage.getItem('senlin-mini-session') || 'null') as SavedSession | null; if (!value?.baseUrl || !value.token) return null; setApiBaseUrl(value.baseUrl); return value } catch { return null } }
+function persistSession(session: SavedSession) { localStorage.setItem(SESSION_KEY, JSON.stringify(session)) }
+function projectLabel(project: Project) { if (project.icon && !['folder', 'project'].includes(project.icon)) return Array.from(project.icon).slice(0, 2).join(''); return Array.from(project.name.trim()).slice(0, 2).join('').toUpperCase() || }
+function formatDate(value: string) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `${date.getMonth() + 1}/${date.getDate()}` }
+function slugify(value: string) { return value.toLowerCase().trim().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-').replace(/^-|-$/g, '') }
+function errorMessage(error: unknown) { return error instanceof ApiError || error instanceof Error ? error.message : '操作失败,请稍后重试' }
diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts
index ae5f784..767209f 100644
--- a/apps/desktop/src/api.ts
+++ b/apps/desktop/src/api.ts
@@ -1,5 +1,7 @@
export type ApiSession = { baseUrl: string; token: string }
+export type CurrentUser = { email: string; displayName: string }
+
export type Project = {
id: string
name: string
@@ -9,6 +11,17 @@ export type Project = {
description: string
}
+export type WorkspaceChannel = {
+ id: string
+ projectId: string
+ type: string
+ title: string
+ icon: string
+ count: number
+ url: string
+ sortOrder: number
+}
+
export type WorkspaceTask = {
id: string
projectId: string
@@ -23,57 +36,27 @@ export type WorkspaceTask = {
tag: string
}
-export type WorkspaceDocument = {
- id: string
- projectId: string
- kind: string
- name: string
- extension: string
- mimeType: string
- updatedAt: string
-}
-
-export type CronPlan = {
- id: string
- projectId: string
- title: string
- schedule: string
- nextRun: string | null
- enabled: boolean
- lastResult: string
- owner: string
-}
+export type WorkspaceDocument = { id: string; projectId: string; kind: string; name: string; extension: string; mimeType: string; updatedAt: string }
+export type WorkspaceAISession = { id: string; projectId: string; title: string; summary: string; updatedAt: string; references: string[] }
+export type WorkspaceInbox = { id: string; projectId: string; source: string; title: string; summary: string; status: string; tag: string; time: string }
+export type WorkspaceCronPlan = { id: string; projectId: string; title: string; schedule: string; nextRun: string | null; enabled: boolean; lastResult: string; owner: string }
export type Workspace = {
project: Project & { initials: string; unreadCount: number }
+ channels: WorkspaceChannel[]
tags: Array<{ id: string; name: string }>
+ recentSessions: WorkspaceAISession[]
+ inbox: WorkspaceInbox[]
tasks: WorkspaceTask[]
+ aiSessions: WorkspaceAISession[]
documents: WorkspaceDocument[]
- aiSessions: Array<{ id: string; projectId: string; title: string; summary: string; updatedAt: string; references: string[] }>
- cronPlans: CronPlan[]
+ cronPlans: WorkspaceCronPlan[]
}
-export type Expert = {
- id: string
- slug: string
- category: string
- categoryName: string
- name: string
- description: string
- emoji: string
- color: string
-}
-
-export type AISession = {
- id: string
- projectId: string
- title: string
- context: string
- status: string
- expert: Expert | null
- createdAt: string
- updatedAt: string
-}
+export type Expert = { id: string; slug: string; category: string; categoryName: string; name: string; description: string; emoji: string; color: string }
+export type AISession = { id: string; projectId: string; title: string; context: string; status: string; expert: Expert | null; createdAt: string; updatedAt: string }
+export type CreateTaskInput = { title: string; description?: string; status?: string; dueAt?: string; tag?: string }
+export type UpdateTaskInput = { title: string; description?: string; status?: string; completed: boolean; nextProjectId?: string; tag?: string }
export class ApiError extends Error {
constructor(public status: number, public code: string, message: string) {
@@ -84,131 +67,101 @@ export class ApiError extends Error {
let baseUrl = normalizeBaseUrl(import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9150')
-export function configureApi(value: string) {
- baseUrl = normalizeBaseUrl(value)
+export function setApiBaseUrl(value: string) { baseUrl = normalizeBaseUrl(value) }
+export function getApiBaseUrl() { return baseUrl }
+
+export async function login(server: string, email: string, password: string): Promise {
+ setApiBaseUrl(server)
+ const response = await apiRequest<{ token: string; user: CurrentUser }>('/api/v1/auth/login', { method: 'POST', body: { email, password } })
+ return { baseUrl, token: response.token, user: response.user }
}
-export async function login(server: string, email: string, password: string): Promise {
- configureApi(server)
- const response = await request<{ token: string }>('/api/v1/auth/login', { method: 'POST', body: { email, password } })
- return { baseUrl, token: response.token }
+export function getCurrentUser(session: ApiSession) {
+ useSessionBase(session)
+ return apiRequest('/api/v1/auth/me', { token: session.token })
+}
+
+export function updateCurrentUser(session: ApiSession, input: { displayName: string; currentPassword?: string; newPassword?: string }) {
+ useSessionBase(session)
+ return apiRequest('/api/v1/auth/me', { method: 'PATCH', token: session.token, body: input })
+}
+
+export function fetchProjects(session: ApiSession) {
+ useSessionBase(session)
+ return apiRequest('/api/v1/projects', { token: session.token })
+}
+
+export function fetchProjectWorkspace(session: ApiSession, projectId: string) {
+ useSessionBase(session)
+ return apiRequest(`/api/v1/projects/${projectId}/workspace`, { token: session.token })
+}
+
+export function createProject(session: ApiSession, input: Pick) {
+ useSessionBase(session)
+ return apiRequest('/api/v1/projects', { method: 'POST', token: session.token, body: input })
+}
+
+export function createTask(session: ApiSession, projectId: string, input: CreateTaskInput) {
+ useSessionBase(session)
+ return apiRequest(`/api/v1/projects/${projectId}/tasks`, { method: 'POST', token: session.token, body: input })
+}
+
+export function updateTask(session: ApiSession, projectId: string, taskId: string, input: UpdateTaskInput) {
+ useSessionBase(session)
+ return apiRequest(`/api/v1/projects/${projectId}/tasks/${taskId}`, { method: 'PATCH', token: session.token, body: input })
+}
+
+export function listAIExperts(session: ApiSession) {
+ useSessionBase(session)
+ return apiRequest('/api/v1/ai-experts', { token: session.token })
+}
+
+export function listAISessions(session: ApiSession, projectId: string) {
+ useSessionBase(session)
+ return apiRequest(`/api/v1/projects/${projectId}/ai-sessions`, { token: session.token })
+}
+
+export function createAISession(session: ApiSession, projectId: string, input: { title: string; context: string; expertId?: string }) {
+ useSessionBase(session)
+ return apiRequest(`/api/v1/projects/${projectId}/ai-sessions`, { method: 'POST', token: session.token, body: input })
}
export async function checkServerConnection(server: string, signal?: AbortSignal) {
try {
const response = await fetch(`${normalizeBaseUrl(server)}/api/v1/status`, { signal })
- if (!response.ok) return false
- const payload = await response.json() as { timestamp?: unknown }
- return typeof payload.timestamp === 'string'
+ return response.ok
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') throw error
return false
}
}
-export async function fetchProjects(session: ApiSession) {
- useSessionBase(session)
- return request('/api/v1/projects', { token: session.token })
-}
+type RequestOptions = { method?: string; token?: string; body?: unknown; responseType?: 'void'; signal?: AbortSignal }
-export async function fetchWorkspace(session: ApiSession, projectId: string) {
- useSessionBase(session)
- return request(`/api/v1/projects/${projectId}/workspace`, { token: session.token })
-}
-
-export async function createProject(session: ApiSession, name: string) {
- useSessionBase(session)
- const identifier = `${slugify(name) || 'project'}-${Date.now().toString(36)}`
- return request('/api/v1/projects', {
- method: 'POST', token: session.token,
- body: { name, identifier, icon: 'folder', background: '#165DFF', description: '' },
- })
-}
-
-export async function createTask(session: ApiSession, projectId: string, title: string, tag?: string) {
- useSessionBase(session)
- return request(`/api/v1/projects/${projectId}/tasks`, {
- method: 'POST', token: session.token, body: { title, description: '', status: 'open', tag },
- })
-}
-
-export async function updateTask(session: ApiSession, projectId: string, task: WorkspaceTask) {
- useSessionBase(session)
- return request(`/api/v1/projects/${projectId}/tasks/${task.id}`, {
- method: 'PATCH', token: session.token,
- body: { title: task.title, description: task.summary, completed: !task.completed, tag: task.tag },
- })
-}
-
-export async function uploadSource(session: ApiSession, projectId: string, file: File) {
- useSessionBase(session)
- const body = new FormData()
- body.append('file', file)
- body.append('title', file.name)
- return request(`/api/v1/projects/${projectId}/sources`, { method: 'POST', token: session.token, body })
-}
-
-export async function createCron(session: ApiSession, projectId: string, title: string, schedule: string) {
- useSessionBase(session)
- return request(`/api/v1/projects/${projectId}/cron-plans`, {
- method: 'POST', token: session.token, body: { title, schedule, enabled: true },
- })
-}
-
-export async function fetchExperts(session: ApiSession) {
- useSessionBase(session)
- return request('/api/v1/ai-experts', { token: session.token })
-}
-
-export async function fetchAISessions(session: ApiSession, projectId: string) {
- useSessionBase(session)
- return request(`/api/v1/projects/${projectId}/ai-sessions`, { token: session.token })
-}
-
-export async function createAISession(session: ApiSession, projectId: string, expertId: string, context: string) {
- useSessionBase(session)
- return request(`/api/v1/projects/${projectId}/ai-sessions`, {
- method: 'POST', token: session.token,
- body: { title: Array.from(context).slice(0, 36).join(''), context, expertId },
- })
-}
-
-type RequestOptions = { method?: string; token?: string; body?: unknown; signal?: AbortSignal }
-
-async function request(path: string, options: RequestOptions = {}): Promise {
- const isForm = options.body instanceof FormData
+export async function apiRequest(path: string, options: RequestOptions = {}): Promise {
+ const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
let response: Response
try {
response = await fetch(`${baseUrl}${path}`, {
method: options.method ?? 'GET',
- headers: {
- ...(!isForm && options.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
- ...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
- },
- body: options.body === undefined ? undefined : isForm ? options.body as FormData : JSON.stringify(options.body),
+ headers: { ...(options.body !== undefined && !isFormData ? { 'Content-Type': 'application/json' } : {}), ...(options.token ? { Authorization: `Bearer ${options.token}` } : {}) },
+ body: options.body === undefined ? undefined : isFormData ? options.body as FormData : JSON.stringify(options.body),
signal: options.signal,
})
} catch {
- throw new ApiError(0, 'network_error', '无法连接服务器,请检查地址和网络')
+ throw new ApiError(0, 'network_error', '无法连接服务器,请检查地址和网络后重试')
}
if (!response.ok) {
const payload = await response.json().catch(() => null) as { error?: { code?: string; message?: string } } | null
- throw new ApiError(response.status, payload?.error?.code ?? 'request_failed', payload?.error?.message ?? '请求失败,请稍后重试')
+ throw new ApiError(response.status, payload?.error?.code ?? fallbackCode(response.status), payload?.error?.message ?? fallbackMessage(response.status))
}
- if (response.status === 204) return undefined as T
- return response.json() as Promise
+ if (response.status === 204 || options.responseType === 'void') return undefined as T
+ const text = await response.text()
+ if (!text.trim()) throw new ApiError(response.status, 'invalid_response', '服务器返回了无法识别的数据')
+ try { return JSON.parse(text) as T } catch { throw new ApiError(response.status, 'invalid_response', '服务器返回了无法识别的数据') }
}
-function useSessionBase(session: ApiSession) {
- baseUrl = normalizeBaseUrl(session.baseUrl)
-}
-
-function normalizeBaseUrl(value: string) {
- const normalized = value.trim().replace(/\/+$/, '')
- if (!normalized || /^https?:\/\//i.test(normalized)) return normalized
- return `http://${normalized}`
-}
-
-function slugify(value: string) {
- return value.toLowerCase().trim().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-').replace(/^-|-$/g, '')
-}
+function useSessionBase(session: ApiSession) { baseUrl = normalizeBaseUrl(session.baseUrl) }
+function normalizeBaseUrl(value: string) { const normalized = value.trim().replace(/\/+$/, ''); return !normalized || /^https?:\/\//i.test(normalized) ? normalized : `http://${normalized}` }
+function fallbackCode(status: number) { return status === 401 ? 'unauthorized' : status === 403 ? 'forbidden' : status === 404 ? 'not_found' : status === 409 ? 'conflict' : status >= 500 ? 'internal_error' : 'request_failed' }
+function fallbackMessage(status: number) { return status === 401 ? '登录已失效,请重新登录' : status === 403 ? '没有权限执行此操作' : status === 404 ? '请求的内容不存在' : status === 409 ? '操作冲突,请刷新后重试' : status >= 500 ? '服务器暂时无法处理请求,请稍后重试' : '请求失败,请稍后重试' }
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index b7ad72e..78a4d63 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -1,235 +1,70 @@
-:root {
- font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
- color: #1d2129;
- background: #e8edf4;
- font-synthesis: none;
- --blue: #165dff;
- --line: #e5e8ef;
- --muted: #86909c;
- --panel: #fff;
- --canvas: #f5f7fa;
-}
-
+:root { font-family: Inter, "Microsoft YaHei", sans-serif; color: #1d2129; background: #f4f5f7; }
* { box-sizing: border-box; }
-html, body, #root { width: 100%; min-width: 320px; height: 100%; margin: 0; }
-body { overflow: hidden; background: #e8edf4; }
-button, input, textarea { font: inherit; }
-button { color: inherit; }
+body { margin: 0; min-width: 300px; min-height: 100vh; background: #f4f5f7; }
+button { font: inherit; }
-.mini-shell {
- width: 100%;
- height: 100vh;
- display: grid;
- grid-template-rows: 44px 50px 58px minmax(0, 1fr) 28px;
- overflow: hidden;
- background: var(--canvas);
- border: 0;
- box-shadow: none;
-}
+.app-shell { --canvas: #f6f7f9; --surface: #fff; --line: #e5e6eb; --text: #1d2129; --muted: #86909c; --rail: #f2f3f5; display: grid; grid-template: 48px 46px minmax(0, 1fr) / minmax(0, 1fr) 58px; min-height: 100vh; color: var(--text); background: var(--canvas); overflow: hidden; }
+.app-shell.theme-dark { --canvas: #171a1f; --surface: #23272e; --line: #3a3f47; --text: #f2f3f5; --muted: #a9b0bb; --rail: #2b3038; color-scheme: dark; }
+.app-header { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; min-width: 0; padding: 0 12px 0 14px; border-bottom: 1px solid var(--line); background: var(--surface); }
+.app-brand { display: flex; align-items: center; gap: 7px; min-width: 0; height: 100%; font-weight: 700; letter-spacing: .1px; }
+.app-brand img { width: 23px; height: 23px; }
+.header-actions { display: flex; align-items: center; gap: 5px; }
+.profile-trigger { display: inline-flex; align-items: center; gap: 6px; max-width: 144px; border: 0; padding: 4px; color: var(--text); background: transparent; border-radius: 8px; cursor: pointer; }
+.profile-trigger:hover { background: color-mix(in srgb, var(--text) 7%, transparent); }
+.profile-trigger > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 600; }
+.profile-trigger > svg { flex: 0 0 auto; color: var(--muted); font-size: 13px; }
-.mini-header,
-.project-switcher,
-.mini-nav,
-.mini-footer {
- background: rgba(255, 255, 255, 0.96);
-}
+.project-strip { grid-column: 1 / -1; display: flex; align-items: center; gap: 4px; min-width: 0; padding: 5px 9px; border-bottom: 1px solid var(--line); background: var(--surface); }
+.project-scroller { display: flex; align-items: center; gap: 4px; min-width: 0; overflow: hidden; flex: 1; }
+.project-pill { display: inline-flex; align-items: center; flex: 0 0 auto; gap: 5px; max-width: 122px; min-height: 30px; padding: 4px 8px; border: 1px solid transparent; border-radius: 8px; color: #4e5969; background: transparent; cursor: pointer; }
+.project-pill:hover, .project-pill.active { color: #165dff; background: #e8f3ff; border-color: #d3e7ff; }
+.project-pill i { display: grid; place-items: center; width: 19px; height: 19px; border-radius: 6px; color: #fff; font-style: normal; font-size: 9px; font-weight: 700; }
+.project-pill span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 600; }
-.mini-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 10px 0 12px;
- border-bottom: 1px solid var(--line);
- user-select: none;
-}
+.app-alert { grid-column: 1 / 2; z-index: 2; margin: 8px 12px -46px; align-self: start; }
+.app-main { grid-column: 1; grid-row: 3; min-width: 0; overflow: auto; padding: 18px 15px 24px; }
+.app-main > .arco-spin { min-height: 100%; }
+.content-page { max-width: 650px; margin: 0 auto; }
+.content-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
+.content-heading .arco-typography { margin: 0; }
+.content-heading h4 { margin-top: 2px; font-size: 20px; }
+.tag-filter { display: flex; gap: 6px; margin-bottom: 16px; overflow-x: auto; padding-bottom: 2px; }
+.tag-filter button { flex: 0 0 auto; padding: 5px 10px; border: 0; border-radius: 999px; color: var(--muted); background: var(--surface); cursor: pointer; font-size: 12px; }
+.tag-filter button.active { color: #fff; background: #165dff; }
+.task-section { margin-bottom: 18px; padding: 13px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }
+.completed-section { opacity: .82; }
+.section-label { display: flex; align-items: center; justify-content: space-between; margin-bottom: 9px; font-size: 13px; }
+.section-label span { display: grid; place-items: center; min-width: 21px; height: 21px; border-radius: 50%; color: #165dff; background: #e8f3ff; font-size: 11px; font-weight: 700; }
+.task-list { display: grid; gap: 7px; }
+.task-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; gap: 8px; width: 100%; padding: 10px 8px; border: 1px solid transparent; border-radius: 9px; color: var(--text); background: transparent; text-align: left; cursor: pointer; }
+.task-row:hover { border-color: #c9d7ee; background: #f7faff; }
+.task-row.done .task-copy strong { color: var(--muted); text-decoration: line-through; }
+.task-check { color: #165dff; font-size: 21px; line-height: 0; }
+.task-row.done .task-check { color: #00b42a; }
+.task-copy { display: grid; min-width: 0; gap: 3px; }
+.task-copy strong, .task-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.task-copy strong { font-size: 13px; }
+.task-copy small { color: var(--muted); font-size: 11px; }
+.task-meta { display: grid; justify-items: end; gap: 4px; }
+.task-meta small { color: var(--muted); font-size: 10px; }
-.mini-brand,
-.project-trigger,
-.mini-footer,
-.section-title,
-.page-title,
-.selected-expert,
-.session-list > div,
-.settings-card > div,
-.version-card {
- display: flex;
- align-items: center;
-}
+.channel-rail { grid-column: 2; grid-row: 3; display: flex; flex-direction: column; align-items: center; gap: 7px; padding: 10px 6px; border-left: 1px solid var(--line); background: var(--rail); overflow-y: auto; }
+.channel-rail button { display: grid; place-items: center; width: 42px; min-height: 43px; gap: 2px; border: 0; border-radius: 10px; color: #6b7077; background: transparent; cursor: pointer; }
+.channel-rail button:hover { color: #165dff; background: #e8f3ff; }
+.channel-rail button.active { color: #165dff; background: #dbeeff; box-shadow: inset 0 0 0 1px #c9e2ff; }
+.channel-rail svg { font-size: 20px; }
+.channel-rail span { font-size: 9px; line-height: 1; }
-.mini-brand { gap: 7px; font-weight: 800; }
-.mini-brand img { width: 25px; height: 25px; }
-.mini-brand .arco-tag { margin-left: 2px; }
+.detail-list { display: grid; gap: 8px; }
+.detail-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; gap: 9px; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); }
+.detail-row > svg { color: #165dff; font-size: 18px; }
+.detail-row div { min-width: 0; }
+.detail-row strong, .detail-row p { display: block; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.detail-row strong { font-size: 13px; }.detail-row p { margin-top: 3px; color: var(--muted); font-size: 11px; }
+.expert-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
+.expert-chips span { display: inline-flex; align-items: center; gap: 4px; padding: 4px 7px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface); font-size: 11px; }.expert-chips i { display: grid; place-items: center; width: 17px; height: 17px; border-radius: 50%; font-style: normal; }
-.project-switcher {
- display: grid;
- grid-template-columns: minmax(0, 1fr) 32px 32px;
- align-items: center;
- gap: 4px;
- padding: 6px 8px;
- border-bottom: 1px solid var(--line);
-}
+.login-page { display: grid; min-height: 100vh; place-items: center; padding: 20px; background: #edf2f9; }.login-card { display: grid; width: min(100%, 360px); gap: 12px; padding: 28px; border: 1px solid #e5e6eb; border-radius: 16px; background: #fff; box-shadow: 0 18px 48px rgba(31, 35, 41, .12); }.login-card > img { width: 34px; }.login-card .arco-typography { margin: 0; }.login-card label { display: grid; gap: 5px; color: #4e5969; font-size: 12px; }
+.theme-dark .project-strip, .theme-dark .app-header, .theme-dark .task-section, .theme-dark .detail-row, .theme-dark .tag-filter button { color: var(--text); background: var(--surface); }.theme-dark .project-pill { color: var(--muted); }.theme-dark .project-pill.active, .theme-dark .channel-rail button.active { color: #8ac7ff; background: #1d4d77; }.theme-dark .task-row:hover { background: #293847; border-color: #477da7; }.theme-dark .section-label span { color: #8ac7ff; background: #1d4d77; }
-.project-switcher > .arco-select { width: 100%; }
-.project-trigger {
- width: 100%;
- min-width: 0;
- height: 36px;
- gap: 8px;
- border: 0;
- border-radius: 9px;
- background: transparent;
- padding: 3px 6px;
- text-align: left;
- cursor: pointer;
-}
-.project-trigger:hover { background: #f2f5fa; }
-.project-trigger > span:nth-of-type(1) { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; }
-.project-trigger small { margin-left: auto; color: var(--muted); font-size: 10px; }
-
-.mini-nav {
- display: grid;
- grid-template-columns: repeat(5, minmax(0, 1fr));
- border-bottom: 1px solid var(--line);
-}
-.mini-nav button {
- position: relative;
- display: grid;
- place-items: center;
- align-content: center;
- gap: 2px;
- border: 0;
- background: transparent;
- color: #6b778c;
- cursor: pointer;
- font-size: 11px;
-}
-.mini-nav button::after { content: ""; position: absolute; left: 22%; right: 22%; bottom: 0; height: 2px; border-radius: 99px; background: transparent; }
-.mini-nav svg { font-size: 18px; }
-.mini-nav button:hover,
-.mini-nav button.active { color: var(--blue); background: linear-gradient(180deg, transparent, #f3f7ff); }
-.mini-nav button.active::after { background: var(--blue); }
-
-.mini-alert { position: fixed; z-index: 30; top: 102px; left: 50%; width: min(388px, calc(100% - 24px)); transform: translateX(-50%); }
-.mini-content { min-height: 0; overflow: hidden; }
-.mini-content > .arco-spin { width: 100%; height: 100%; }
-.mini-content > .arco-spin > .arco-spin-children { height: 100%; }
-.mini-page { height: 100%; overflow-x: hidden; overflow-y: auto; padding: 14px 14px 24px; scrollbar-width: thin; scrollbar-color: #c9cdd4 transparent; }
-
-.mini-footer { justify-content: space-between; padding: 0 10px; border-top: 1px solid var(--line); color: var(--muted); font-size: 10px; }
-.mini-footer span { display: inline-flex; align-items: center; gap: 5px; }
-.mini-footer i { width: 6px; height: 6px; border-radius: 50%; background: #00b42a; box-shadow: 0 0 0 2px #e8ffea; }
-.mini-footer button { border: 0; background: transparent; color: var(--muted); cursor: pointer; font-size: 10px; }
-
-.page-intro {
- position: relative;
- overflow: hidden;
- border-radius: 16px;
- background: linear-gradient(135deg, #165dff, #6aa1ff);
- color: white;
- padding: 18px;
- box-shadow: 0 12px 28px rgba(22, 93, 255, 0.2);
-}
-.page-intro::after { content: ""; position: absolute; width: 130px; height: 130px; right: -45px; top: -52px; border: 24px solid rgba(255,255,255,.1); border-radius: 50%; }
-.page-intro .arco-typography { color: rgba(255,255,255,.78); }
-.page-intro h4 { margin: 4px 0 5px; color: white; }
-.page-intro p { position: relative; z-index: 1; margin: 0; color: rgba(255,255,255,.86); font-size: 12px; line-height: 1.6; }
-
-.metric-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-top: 12px; }
-.metric { display: grid; gap: 2px; border: 1px solid var(--line); border-radius: 12px; background: white; padding: 12px; }
-.metric strong { font-size: 22px; }
-.metric span { color: var(--muted); font-size: 11px; }
-.metric.blue strong { color: #165dff; }.metric.green strong { color: #00b42a; }.metric.purple strong { color: #722ed1; }.metric.orange strong { color: #ff7d00; }
-
-.section-title { justify-content: space-between; gap: 8px; margin: 18px 2px 9px; font-size: 13px; }
-.quick-actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 9px; }
-.quick-actions button { display: flex; align-items: center; gap: 10px; border: 1px solid var(--line); border-radius: 12px; background: white; padding: 12px; text-align: left; cursor: pointer; }
-.quick-actions button:hover { border-color: #94bfff; box-shadow: 0 5px 16px rgba(22,93,255,.1); }
-.quick-actions svg { color: var(--blue); font-size: 20px; }
-.quick-actions span, .compact-list span, .session-list span, .settings-card span { min-width: 0; display: grid; gap: 2px; }
-.quick-actions small, .compact-list small, .session-list small, .settings-card small { overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
-
-.compact-list { overflow: hidden; border: 1px solid var(--line); border-radius: 12px; background: white; }
-.compact-list > div { display: grid; grid-template-columns: 18px minmax(0,1fr); gap: 9px; align-items: center; padding: 10px 12px; border-bottom: 1px solid #f0f1f4; }
-.compact-list > div:last-child { border-bottom: 0; }
-.compact-list svg { color: var(--blue); }
-
-.page-title { justify-content: space-between; gap: 10px; margin-bottom: 13px; }
-.page-title h5 { margin: 0 0 2px; }
-.page-title .arco-typography { font-size: 11px; }
-.filter-strip { display: flex; gap: 6px; overflow-x: auto; margin: 10px 0 12px; padding-bottom: 2px; scrollbar-width: none; }
-.filter-strip button { flex: 0 0 auto; border: 1px solid var(--line); border-radius: 999px; background: white; color: var(--muted); padding: 4px 10px; cursor: pointer; font-size: 11px; }
-.filter-strip button.active { border-color: #8eb7ff; background: #edf3ff; color: var(--blue); }
-
-.sticky-list, .note-grid { display: grid; gap: 9px; }
-.task-note { display: grid; grid-template-columns: 28px minmax(0,1fr); gap: 9px; border: 1px solid #eadfac; border-radius: 4px 13px 13px 13px; background: #fffbea; padding: 13px 12px 12px 9px; box-shadow: 0 4px 14px rgba(78, 89, 105, .08); }
-.task-note:nth-child(3n+2) { border-color: #cde5d7; background: #f0fff5; }
-.task-note:nth-child(3n+3) { border-color: #d7def4; background: #f2f5ff; }
-.task-note.completed { opacity: .64; }
-.task-note.completed strong { text-decoration: line-through; }
-.task-check { width: 27px; height: 27px; display: grid; place-items: center; border: 0; border-radius: 50%; background: rgba(255,255,255,.8); color: #8b9aab; cursor: pointer; }
-.task-note:not(.completed) .task-check:hover, .task-note.completed .task-check { color: #00b42a; }
-.task-note strong { font-size: 13px; }
-.task-note p { margin: 5px 0; color: #4e5969; font-size: 11px; line-height: 1.5; }
-.task-note span { color: var(--muted); font-size: 10px; }
-
-.hidden-file { display: none; }
-.note-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }
-.source-note { min-height: 135px; display: flex; flex-direction: column; border: 1px solid #eadfac; border-radius: 4px 14px 14px 14px; background: #fff9d8; padding: 13px; }
-.source-note.tone-1 { background: #eaf8ff; border-color: #c7e6f5; }.source-note.tone-2 { background: #f3edff; border-color: #ded0f7; }.source-note.tone-3 { background: #edfff3; border-color: #caead4; }
-.source-note svg { color: #ff9a2e; font-size: 18px; }
-.source-note strong { margin-top: 9px; font-size: 12px; }
-.source-note p { flex: 1; margin: 5px 0; color: #4e5969; font-size: 10px; }
-.source-note span { color: var(--muted); font-size: 9px; }
-
-.ai-view > .arco-input-wrapper { border-radius: 10px; }
-.expert-strip { max-height: 210px; display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 7px; overflow-y: auto; padding: 1px; }
-.expert-strip button { min-width: 0; display: grid; grid-template-columns: 34px minmax(0,1fr); align-items: center; gap: 8px; border: 1px solid var(--line); border-radius: 10px; background: white; padding: 8px; text-align: left; cursor: pointer; }
-.expert-strip button.active { border-color: #75a8ff; background: #f2f6ff; box-shadow: 0 0 0 1px #c8dcff; }
-.expert-strip i, .selected-expert i, .session-list i { display: grid; place-items: center; border-radius: 9px; color: white; font-style: normal; }
-.expert-strip i { width: 34px; height: 34px; font-size: 17px; }
-.expert-strip span { min-width: 0; display: grid; }
-.expert-strip b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
-.expert-strip small { color: var(--muted); font-size: 9px; }
-
-.mini-composer { display: grid; gap: 8px; margin-top: 12px; border: 1px solid var(--line); border-radius: 14px; background: white; padding: 11px; box-shadow: 0 7px 22px rgba(29,33,41,.08); }
-.selected-expert { min-height: 38px; gap: 9px; }
-.selected-expert i { width: 36px; height: 36px; flex: 0 0 36px; }
-.selected-expert span { min-width: 0; display: grid; }
-.selected-expert b { font-size: 12px; }
-.selected-expert small { overflow: hidden; color: var(--muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
-.mini-composer .arco-textarea-wrapper { border-radius: 9px; background: #f6f7f9; }
-.session-list { display: grid; gap: 7px; }
-.session-list > div { gap: 9px; border: 1px solid var(--line); border-radius: 10px; background: white; padding: 9px; }
-.session-list i { width: 31px; height: 31px; }
-
-.settings-card { display: grid; gap: 10px; margin-bottom: 10px; border: 1px solid var(--line); border-radius: 13px; background: white; padding: 13px; }
-.settings-card > strong { font-size: 13px; }
-.settings-card > div { gap: 9px; border-top: 1px solid #f0f1f4; padding-top: 9px; }
-.settings-card svg { color: var(--blue); }
-.settings-card p { margin: 0; color: #4e5969; font-size: 11px; line-height: 1.65; }
-.version-card { grid-template-columns: 34px minmax(0,1fr); }
-.version-card img { width: 34px; }
-
-.mini-login { width: 100%; height: 100%; display: grid; place-items: center; overflow: auto; padding: 22px; background: linear-gradient(160deg, #edf4ff 0%, #e6ebf2 52%, #dce4ef 100%); }
-.login-note { width: min(360px, 100%); display: grid; justify-items: stretch; gap: 13px; border: 1px solid rgba(255,255,255,.85); border-radius: 4px 22px 22px 22px; background: #fffbea; padding: 26px 22px 20px; box-shadow: 0 24px 60px rgba(29,33,41,.16); }
-.login-note > img { width: 52px; }
-.login-note h3 { margin: 0; }
-.login-note label, .mini-form label { display: grid; gap: 5px; color: #4e5969; font-size: 11px; }
-.login-note .arco-input-wrapper, .mini-form .arco-input-wrapper { border-radius: 9px; background: rgba(255,255,255,.82); }
-.login-note > small { color: var(--muted); text-align: center; }
-.mini-connection { display: grid; grid-template-columns: 9px 1fr auto; align-items: center; gap: 8px; margin-top: -5px; border: 1px solid #bedaff; border-radius: 9px; background: #f2f8ff; padding: 8px 10px; color: #165dff; font-size: 11px; }
-.mini-connection > i { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
-.mini-connection > strong { font-weight: 600; }
-.mini-connection > button { border: 0; background: transparent; padding: 0; color: inherit; cursor: pointer; font-size: 11px; }
-.mini-connection.online { border-color: #7be188; background: #f0fff4; color: #168a2f; }
-.mini-connection.offline { border-color: #f53f3f; background: #fff2f0; color: #d91d35; }
-
-.mini-modal .arco-modal { width: min(380px, calc(100vw - 24px)); }
-.mini-modal .arco-modal-content { border-radius: 15px; }
-.mini-form { display: grid; gap: 13px; }
-.mini-form .arco-select { width: 100%; }
-
-@media (max-width: 359px) {
- .mini-brand .arco-tag { display: none; }
- .note-grid, .expert-strip { grid-template-columns: 1fr; }
-}
+@media (max-width: 360px) { .profile-trigger > span { display: none; }.project-pill { max-width: 90px; }.app-main { padding: 13px 9px 20px; }.task-meta .arco-tag { max-width: 64px; overflow: hidden; }.content-heading h4 { font-size: 18px; } }