Compare commits

..
Author SHA1 Message Date
weichen a3c556cd0a Surface cross-session controls in the UI with a project settings panel
Why: the layered permission model (project gate + per-conversation
outbound/inbound flags) shipped in Backend PR #18 needs visible
controls; project scope also had no settings UI yet though the API
existed.

Evidence: npm run build (tsc -b + vite build) and npm test (15/15)
pass.

Impact: new schema-driven ProjectSettingsModal (first project-scope
settings surface, writes putProjectSettings, reuses SettingsModal
group/input helpers); TaskSidebar gains a private shield on rows and
in the row menu, per-conversation allowCrossRead in the row menu, and
a project-settings gear on project group heads; Conversation header
gains a cross-read toggle; contracts/api/store carry the new flags.

Refs: proposals/2026-09-30-cross-session-reading.md; Backend PR #18;
Dev task feature/cross-session-reading.
2026-09-30 15:19:46 +00:00
weichen b2fbcf72bc Merge pull request 'Consume the UI capability platform (avatars, settings shell)' (#14) from feature/ui-capability-platform into main 2026-09-30 14:23:17 +00:00
weichen 59ffe044b5 Consume the UI capability platform: shared icons/avatars, settings shell
Why: avatars, cards, and settings are base capabilities now (user
decision 2026-09-30); the host must eat its own dogfood so plugins get
the identical surfaces.

Evidence:
- Local icons.tsx removed; Icon/Avatar/BrandMark/primitives/Settings
  Shell imported from @ui; message stream gains avatar columns (user
  initial from /api/me name, assistant brand mark); SettingsModal
  rebuilt on SettingsShell (side-nav sections with badges, EmptyState
  for plugins, iconified actions); AdminUsersModal rows show shared
  avatars.
- tsconfig paths pin react/react-dom types to the frontend copy — the
  source-composed ui package otherwise resolves a second @types/react
  through its own node_modules during make frontend (npm ci), which
  breaks type identity (surfaced by SVGProps crossing the boundary);
  vite dedupe already guarantees one runtime copy.
- Headless on the real binary: avatar columns render, settings shell
  switches sections with badges, admin rows show avatars, zero console
  errors; build + vitest green.

Impact: visual/structural only; no API or state changes.

Refs: Agent-System-Documents proposal 2026-09-30-ui-redesign-and-auth.md
2026-09-30 14:22:37 +00:00
weichen 01672deec5 Merge pull request 'Restyle the whole UI: linear icons, layered surfaces, refined chrome (R4)' (#13) from feature/visual-refresh-r4 into main 2026-09-30 12:44:34 +00:00
15 changed files with 168 additions and 134 deletions
+3 -1
View File
@@ -3,7 +3,7 @@ import { auth } from './auth'
import { api } from './api'
import { LoginPage, ChangePasswordGate } from './components/LoginPage'
import { AdminUsersModal, ChangePasswordModal } from './components/AdminUsersModal'
import { Icon } from './icons'
import { Icon } from '@ui'
import { store, useStore } from './store'
import { TaskSidebar } from './components/TaskSidebar'
import { Conversation } from './components/Conversation'
@@ -11,6 +11,7 @@ import { Workbench } from './components/Workbench'
import { Workspaces, workspaceShortcuts } from './components/Workspaces'
import { AgentTracker } from './components/AgentTracker'
import { SettingsModal } from './components/SettingsModal'
import { ProjectSettingsModal } from './components/ProjectSettingsModal'
import { DocViewer } from './components/DocViewer'
// 三栏布局:左(项目会话)/ 中(对话)/ 右(工作台);分隔条拖拽与超调收起(4.4)。
@@ -146,6 +147,7 @@ export function App() {
<Workspaces />
{trackerRunId && <AgentTracker />}
{ui.settingsOpen && <SettingsModal />}
{ui.projectSettingsId && <ProjectSettingsModal />}
{ui.adminUsersOpen && <AdminUsersModal onClose={() => store.setUI({ adminUsersOpen: false })} />}
{ui.changePwOpen && <ChangePasswordModal onClose={() => store.setUI({ changePwOpen: false })} />}
{viewingArtifact && <DocViewer artifact={viewingArtifact} />}
+1 -1
View File
@@ -26,7 +26,7 @@ export const api = {
listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<Conversation[]>(r)),
// 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。
listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json<Conversation[]>(r)),
setConversationFlags: (id: string, flags: { pinned?: boolean; archived?: boolean }) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
setConversationFlags: (id: string, flags: { pinned?: boolean; archived?: boolean; allowCrossRead?: boolean; crossPrivate?: boolean }) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
// ---- 认证与会话(管理员发放制,R2)----
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
+2 -2
View File
@@ -4,7 +4,7 @@
import { useEffect, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import { Icon } from '../icons'
import { Icon, Avatar } from '@ui'
import { randomPassword } from '../passwords'
interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
@@ -100,7 +100,7 @@ export function AdminUsersModal({ onClose }: { onClose: () => void }) {
<tbody>
{(users ?? []).map(u => (
<tr key={u.owner}>
<td className="ad-mono">{u.owner}</td>
<td className="ad-user-cell"><Avatar name={u.name || u.owner} size="sm" /><span className="ad-mono">{u.owner}</span></td>
<td>{u.name || '—'}</td>
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
+13 -2
View File
@@ -1,5 +1,5 @@
// 中栏:消息流(Part 渲染器注册表)+ 流式渲染 + 过程折叠 + HITL 卡片 + 输入区(4.4)。
import { Icon } from '../icons'
import { Icon, Avatar, BrandMark } from '@ui'
import { useEffect, useMemo, useRef, useState } from 'react'
import { marked } from 'marked'
import { api } from '../api'
@@ -97,6 +97,7 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
return (
<div className="msg assistant">
<div className="msg-role">
<span className="role-mark role-assistant"><BrandMark size={16} /></span>
<span>助手</span>
{live && <span className="live-bar"><span className="live-dot" />{live.phase}</span>}
</div>
@@ -216,6 +217,7 @@ export function Conversation() {
const currentConvId = useStore(s => s.currentConvId)
const conversation = useStore(s => s.tasks.find(c => c.id === s.currentConvId))
const messages = useStore(s => s.messages)
const myName = useStore(s => s.me?.name || s.me?.owner || '你')
const activeHitl = useStore(s => s.activeHitl)
const live = useStore(s => s.live)
const runs = useStore(s => s.runs)
@@ -270,6 +272,13 @@ export function Conversation() {
}}>
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<button className="btn ghost" style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
title={conversation?.allowCrossRead === false
? '本会话不读取其他会话记录(点击开启)'
: '本会话可读取同项目其他会话记录(需项目设置开启跨会话读取;点击关闭)'}
onClick={() => currentConvId && store.setCrossFlags(currentConvId, { allowCrossRead: conversation?.allowCrossRead === false })}>
<Icon name="users" size={13} />跨读{conversation?.allowCrossRead === false ? '·关' : '·开'}
</button>
<span title={connected ? '事件流已连接' : '事件流重连中'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
{active && <button className="btn ghost" onClick={() => store.cancelActive()}>停止</button>}
</div>
@@ -290,7 +299,9 @@ export function Conversation() {
const anchored = cards.filter(c => c.anchorMessageId === m.id && c.kind === 'confirm')
return (
<div className="msg user" key={m.id}>
<div className="msg-role">你</div>
<div className="msg-role">
<span className="role-mark"><Avatar name={myName} size="sm" /></span>你
</div>
<div className="bubble">{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}</div>
{anchored.map(c => (
<InlineConfirmCard key={c.id} card={c} callbacks={{
+1 -1
View File
@@ -4,7 +4,7 @@
import { useState } from 'react'
import { auth } from '../auth'
import { api } from '../api'
import { Icon, BrandMark } from '../icons'
import { Icon, BrandMark } from '@ui'
function Err({ msg }: { msg: string }) {
if (!msg) return null
+86
View File
@@ -0,0 +1,86 @@
// 项目设置面板(提案 2026-09-30-cross-session-reading):首个项目级设置 UI。
// schema 驱动渲染 project scope(4.9 覆盖链:system → user → project),
// 写 putProjectSettings;后续项目级设置项经 settings schema 自动出现,零开发。
import { useEffect, useMemo, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import type { SettingDesc } from '../contracts'
import { SettingsShell, Btn, EmptyState } from '@ui'
import { SettingInput, flatten, unflatten, groupNames } from './SettingsModal'
export function ProjectSettingsModal() {
const projectId = useStore(s => s.ui.projectSettingsId)
const projects = useStore(s => s.projects)
const project = projects.find(p => p.id === projectId)
const [schema, setSchema] = useState<SettingDesc[]>([])
const [draft, setDraft] = useState<Record<string, any>>({})
const [loaded, setLoaded] = useState(false)
const [saved, setSaved] = useState(false)
useEffect(() => {
if (!projectId) return
setLoaded(false)
api.getSettings(projectId).then(s => {
setSchema(s.schema)
setDraft(flatten(s.project))
setLoaded(true)
}).catch(() => undefined)
}, [projectId])
const groups = useMemo(() => {
const g: Record<string, SettingDesc[]> = {}
for (const d of schema) (g[d.group] ??= []).push(d)
return g
}, [schema])
if (!projectId) return null
const save = async () => {
if (!projectId) return
try {
await api.putProjectSettings(projectId, unflatten(draft))
setSaved(true)
setTimeout(() => setSaved(false), 1500)
} catch (e) {
store.toast(`项目设置保存失败:${e}`)
}
}
return (
<SettingsShell
title={`项目设置 · ${project?.name ?? projectId}`}
active="project"
onSelect={() => undefined}
onClose={() => store.openProjectSettings(null)}
sections={[{ key: 'project', label: '本项目', icon: 'folder' }]}
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
>
<div>
{!loaded ? (
<EmptyState icon="settings">加载中…</EmptyState>
) : (
<>
<div className="empty-note" style={{ marginBottom: 10 }}>
此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。
</div>
{Object.entries(groups).map(([g, descs]) => (
<div className="settings-group" key={g}>
<h4>{groupNames[g] ?? g}</h4>
{descs.map(d => (
<div className="setting-row" key={d.key}>
<label title={d.key}>{d.label}</label>
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
<span className={`effect-badge effect-${d.effect}`} title={`生效时机:${d.effect}`}>
{d.effect === 'hot' ? '立即' : d.effect === 'new-run' ? '新任务' : '需重启'}
</span>
</div>
))}
</div>
))}
<Btn variant="primary" icon="check" onClick={save}>保存项目设置</Btn>
</>
)}
</div>
</SettingsShell>
)
}
+27 -31
View File
@@ -3,9 +3,16 @@ import { useEffect, useMemo, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import type { AgentProfile, SettingDesc } from '../contracts'
import { SettingsShell, Btn, EmptyState } from '@ui'
type Section = 'settings' | 'profiles' | 'plugins'
// 分组显示名(项目设置面板共用)。
export const groupNames: Record<string, string> = {
models: '模型与密钥', runtime: '运行时', context: '上下文', retrieval: '检索',
ui: '界面', plugins: '插件目录', crossSession: '会话协作', tools: '工具',
}
export function SettingsModal() {
const profiles = useStore(s => s.profiles)
const [section, setSection] = useState<Section>('settings')
@@ -28,11 +35,6 @@ export function SettingsModal() {
return g
}, [schema])
const groupNames: Record<string, string> = {
models: '模型与密钥', runtime: '运行时', context: '上下文', retrieval: '检索',
ui: '界面', plugins: '插件目录',
}
const save = async () => {
const values = unflatten(draft)
await api.putUserSettings(values)
@@ -45,22 +47,17 @@ export function SettingsModal() {
}
return (
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) store.openSettings(false) }}>
<div className="modal">
<div className="modal-header">
⚙ 设置
<div className="tabs" style={{ margin: '0 16px', borderBottom: 'none' }}>
{(['settings', 'profiles', 'plugins'] as Section[]).map(s => (
<button key={s} className={`tab ${section === s ? 'active' : ''}`} onClick={() => setSection(s)}>
{s === 'settings' ? '设置' : s === 'profiles' ? '智能体画像' : '插件'}
</button>
))}
</div>
<span className="spacer" />
{saved && <span style={{ color: 'var(--ok)', marginRight: 10, fontSize: 12.5 }}>已保存</span>}
<button className="btn ghost" onClick={() => store.openSettings(false)}>关闭</button>
</div>
<div className="modal-body">
<SettingsShell
title="设置" active={section} onSelect={k => setSection(k as Section)}
onClose={() => store.openSettings(false)}
sections={[
{ key: 'settings', label: '常规', icon: 'settings' },
{ key: 'profiles', label: '智能体画像', icon: 'bot', badge: profiles.length || undefined },
{ key: 'plugins', label: '插件', icon: 'puzzle' },
]}
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : 'Agent-System · 设置中心'}
>
<div>
{section === 'settings' && (
<>
{Object.entries(groups).map(([g, descs]) => (
@@ -77,18 +74,17 @@ export function SettingsModal() {
))}
</div>
))}
<button className="btn" onClick={save}>保存设置</button>
<Btn variant="primary" icon="check" onClick={save}>保存设置</Btn>
</>
)}
{section === 'profiles' && <ProfilesSection profiles={profiles} />}
{section === 'plugins' && <PluginsSection />}
</div>
</div>
</div>
</SettingsShell>
)
}
function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) {
export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) {
if (desc.type === 'boolean') {
return <input type="checkbox" checked={value === true || value === 'true'} onChange={e => onChange(e.target.checked)} />
}
@@ -111,12 +107,12 @@ function flat(m: Record<string, any>, prefix: string, out: Record<string, any>)
else out[key] = v
}
}
function flatten(m: Record<string, any>): Record<string, any> {
export function flatten(m: Record<string, any>): Record<string, any> {
const out: Record<string, any> = {}
flat(m, '', out)
return out
}
function unflatten(flatMap: Record<string, any>): Record<string, any> {
export function unflatten(flatMap: Record<string, any>): Record<string, any> {
const out: Record<string, any> = {}
for (const [k, v] of Object.entries(flatMap)) {
if (v === '' || v === undefined) continue // 空串不覆盖(密钥未修改)
@@ -135,7 +131,7 @@ function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
return (
<div>
<div className="doc-toolbar">
<button className="btn" onClick={() => { setEditing(newProfile()); setText('') }}>+ 新建画像</button>
<Btn variant="primary" icon="plus" onClick={() => { setEditing(newProfile()); setText('') }}>新建画像</Btn>
<label className="btn ghost" style={{ cursor: 'pointer' }}>
导入 JSON<input type="file" accept=".json" style={{ display: 'none' }} onChange={async e => {
const f = e.target.files?.[0]
@@ -207,15 +203,15 @@ function PluginsSection() {
return (
<div>
<div className="doc-toolbar">
<button className="btn" onClick={async () => {
<Btn variant="ghost" icon="undo" onClick={async () => {
await api.reloadPlugins()
await refresh()
store.refreshPlugins()
store.toast('插件已热重载')
}}>⟳ 热重载</button>
}}>热重载</Btn>
<span style={{ color: 'var(--text-faint)', fontSize: 12 }}>声明资产(工作流/意图/提示词/面板)热加载;装卸不影响核心</span>
</div>
{plugins.length === 0 && <div style={{ color: 'var(--text-faint)' }}>暂无已装载插件(目录:设置 → 插件目录;示例插件见 Dev 仓 plugins/example)</div>}
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br/>(目录:常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
{plugins.map(p => (
<div className="setting-row" key={p.manifest.id}>
<label>{p.manifest.name} <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>v{p.manifest.version}</span></label>
+10 -1
View File
@@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react'
import { store, useStore } from '../store'
import { auth } from '../auth'
import { api } from '../api'
import { Icon, BrandMark, workspaceIcon } from '../icons'
import { Icon, BrandMark, workspaceIcon } from '@ui'
import type { Conversation } from '../contracts'
import { workspaceEntries } from './Workspaces'
import { groupByProject, groupByTimeline, relativeTime, filterTasks, type TaskOrg } from '../tasklist'
@@ -93,6 +93,10 @@ export function TaskSidebar() {
<button className="tsb-row-act" title={t.archived ? '恢复' : '归档'} onClick={e => { e.stopPropagation(); store.archiveTask(t.id, !t.archived) }}>
<Icon name={t.archived ? 'undo' : 'archive'} size={13} />
</button>
<button className={`tsb-row-act ${t.crossPrivate ? 'active' : ''}`} title={t.crossPrivate ? '私有会话:不被其他会话读取(点击取消)' : '设为私有(不被其他会话读取)'}
onClick={e => { e.stopPropagation(); store.setCrossFlags(t.id, { crossPrivate: !t.crossPrivate }) }}>
<Icon name="shield" size={13} />
</button>
</span>
)
@@ -101,6 +105,7 @@ export function TaskSidebar() {
onClick={() => store.openTask(t.id)}
onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}>
<span className="tsb-task-t" title={t.title}>{t.title || '未命名任务'}</span>
{t.crossPrivate && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
{badge && <span className="tsb-pbadge">{badge}</span>}
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
{rowActions(t)}
@@ -176,6 +181,7 @@ export function TaskSidebar() {
<span className="tsb-group-name" title={g.label}>{g.label}</span>
<span className="tsb-group-cnt">{g.tasks.length}</span>
<span style={{ flex: 1 }} />
<button className="tsb-group-add" title="项目设置(含跨会话读取)" onClick={e => { e.stopPropagation(); if (g.projectId) store.openProjectSettings(g.projectId) }}><Icon name="settings" size={13} /></button>
<button className="tsb-group-add" title="在此项目新建任务" onClick={e => { e.stopPropagation(); store.createTask(g.projectId) }}><Icon name="plus" size={13} /></button>
</div>
) : (
@@ -224,6 +230,9 @@ export function TaskSidebar() {
<MenuItem icon="pin" onClick={() => { store.pinTask(rowTask.id, !rowTask.pinned); setMenu(null) }}>{rowTask.pinned ? '取消置顶' : '置顶'}</MenuItem>
<MenuItem icon={rowTask.archived ? 'undo' : 'archive'} onClick={() => { store.archiveTask(rowTask.id, !rowTask.archived); setMenu(null) }}>{rowTask.archived ? '恢复(移出归档)' : '归档'}</MenuItem>
<div className="tsb-menu-div" />
<MenuItem icon="shield" onClick={() => { store.setCrossFlags(rowTask.id, { crossPrivate: !rowTask.crossPrivate }); setMenu(null) }}>{rowTask.crossPrivate ? '取消私有(可被其他会话读取)' : '设为私有(不被其他会话读取)'}</MenuItem>
<MenuItem icon="users" onClick={() => { store.setCrossFlags(rowTask.id, { allowCrossRead: rowTask.allowCrossRead === false }); setMenu(null) }}>{rowTask.allowCrossRead === false ? '开启读取其他会话' : '关闭读取其他会话'}</MenuItem>
<div className="tsb-menu-div" />
<MenuItem icon="clock" onClick={() => { store.setUI({ taskOrg: 'timeline' }); setMenu(null) }}>按时间线查看</MenuItem>
</>}
</Menu>
+1 -1
View File
@@ -3,7 +3,7 @@
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import { InboxList, pendingCount, type ActivityCard, type DocBridge } from '@ui'
import { Icon } from '../icons'
import { Icon } from '@ui'
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
export const docBridge: DocBridge = {
+3 -1
View File
@@ -15,6 +15,7 @@ export interface Project {
export interface Conversation {
id: ID; projectId: ID; owner?: ID; title: string; profileId?: ID;
pinned?: boolean; archived?: boolean;
allowCrossRead?: boolean; crossPrivate?: boolean;
createdAt: string; updatedAt: string;
}
@@ -153,7 +154,7 @@ export interface HITLResponse {
export type UINodeType =
| 'section' | 'list' | 'kv' | 'form' | 'markdown' | 'doc-ref' | 'actions' | 'chart-lite'
| 'progress' | 'compare' | 'tree' | 'table' | 'steps' | 'tabs' | 'timeline';
| 'avatar' | 'progress' | 'compare' | 'tree' | 'table' | 'steps' | 'tabs' | 'timeline';
export interface UINode {
type: UINodeType;
@@ -167,6 +168,7 @@ export interface UINode {
chart?: { kind: 'bar' | 'line'; labels: string[]; series: number[] };
children?: UINode[];
avatar?: { name?: string; sub?: string; tone?: string; stack?: { name?: string; sub?: string; tone?: string }[] };
progress?: { label?: string; value: number; tone?: string };
compare?: {
left: { title?: string; tone?: string; nodes?: UINode[] };
-91
View File
@@ -1,91 +0,0 @@
// 线性图标集(R4 视觉收敛;Lucide 风格:16px 画布、1.6 描边、圆角端点)。
// 统一替换此前散落各处的 emoji/字符图标(emoji 跨平台渲染不一致且观感廉价)。
import type { ButtonHTMLAttributes, ReactNode, SVGProps } from 'react'
export type IconName =
| 'plus' | 'search' | 'panel-left' | 'inbox' | 'settings' | 'sun' | 'moon'
| 'pin' | 'archive' | 'undo' | 'chevron-right' | 'chevron-down'
| 'folder' | 'message' | 'package' | 'bot' | 'puzzle' | 'grid'
| 'arrow-up' | 'paperclip' | 'more' | 'logout' | 'key' | 'user' | 'users'
| 'shield' | 'x' | 'check' | 'alert' | 'send' | 'sparkles' | 'clock' | 'list'
const PATHS: Record<IconName, ReactNode> = {
plus: <path d="M8 3.5v9M3.5 8h9" />,
search: <><circle cx="7" cy="7" r="4.2" /><path d="m13.2 13.2-2.9-2.9" /></>,
'panel-left': <><rect x="1.8" y="2.8" width="12.4" height="10.4" rx="2" /><path d="M6 2.8v10.4" /></>,
inbox: <><path d="M1.8 9.4 3.6 3.6a1.6 1.6 0 0 1 1.5-1h5.8a1.6 1.6 0 0 1 1.5 1l1.8 5.8" /><path d="M1.8 9.4h3.3l.9 1.8h4l.9-1.8h3.3v2.9a1.5 1.5 0 0 1-1.5 1.5H3.3a1.5 1.5 0 0 1-1.5-1.5Z" /></>,
settings: <><circle cx="8" cy="8" r="2.1" /><path d="M12.9 6.9a1 1 0 0 0 .2 1.1l.1.1a1.2 1.2 0 1 1-1.7 1.7l-.1-.1a1 1 0 0 0-1.7.7v.1a1.2 1.2 0 1 1-2.4 0v-.1a1 1 0 0 0-1.7-.7l-.1.1a1.2 1.2 0 1 1-1.7-1.7l.1-.1a1 1 0 0 0 .2-1.1 1 1 0 0 0-.6-.6H3.5a1.2 1.2 0 1 1 0-2.4h.1a1 1 0 0 0 .7-1.7l-.1-.1a1.2 1.2 0 1 1 1.7-1.7l.1.1a1 1 0 0 0 1.1.2h.1a1 1 0 0 0 .6-.6V1.9a1.2 1.2 0 1 1 2.4 0v.1a1 1 0 0 0 1.7.7l.1-.1a1.2 1.2 0 1 1 1.7 1.7l-.1.1a1 1 0 0 0-.2 1.1v.1a1 1 0 0 0 .6.6h.1a1.2 1.2 0 1 1 0 2.4h-.1a1 1 0 0 0-.6.6Z" /></>,
sun: <><circle cx="8" cy="8" r="3" /><path d="M8 1.3v1.4M8 13.3v1.4M1.3 8h1.4M13.3 8h1.4M3.3 3.3l1 1M11.7 11.7l1 1M12.7 3.3l-1 1M4.3 11.7l-1 1" /></>,
moon: <path d="M13.4 9.6A6 6 0 0 1 6.4 2.6a6 6 0 1 0 7 7Z" />,
pin: <path d="M9.3 1.9 14.1 6.7l-1.4 1.4-.7-.2-2.9 2.9.3 1.6-1.2 1.2L6 10.9l-3.3 3.4-1-1L5 9.9 2.4 7.4l1.2-1.2 1.6.3L8.1 3.6l-.2-.7Z" />,
archive: <><rect x="1.8" y="2.5" width="12.4" height="3" rx="1" /><path d="M3 5.5v7a1.5 1.5 0 0 0 1.5 1.5h7a1.5 1.5 0 0 0 1.5-1.5v-7M6.5 8.8h3" /></>,
undo: <><path d="M2.7 6.8h6.5a3.6 3.6 0 1 1 0 7.2H6" /><path d="m5.4 4-2.7 2.8 2.7 2.8" /></>,
'chevron-right': <path d="m6 3.5 4.5 4.5L6 12.5" />,
'chevron-down': <path d="m3.5 6 4.5 4.5L12.5 6" />,
folder: <path d="M1.8 4.8a1.5 1.5 0 0 1 1.5-1.5h3l1.5 1.7h4.9a1.5 1.5 0 0 1 1.5 1.5v5.7a1.5 1.5 0 0 1-1.5 1.5H3.3a1.5 1.5 0 0 1-1.5-1.5Z" />,
message: <path d="M14 8.7c0 2.6-2.7 4.7-6 4.7a7 7 0 0 1-2-.3L2.3 14.3l.9-2.8A4.4 4.4 0 0 1 2 8.7c0-2.6 2.7-4.7 6-4.7s6 2.1 6 4.7Z" />,
package: <><path d="m8 1.7 5.8 3.1v6.4L8 14.3l-5.8-3.1V4.8Z" /><path d="m2.2 4.8 5.8 3.1 5.8-3.1M8 7.9v6.4" /></>,
bot: <><rect x="3.2" y="5.4" width="9.6" height="7" rx="2" /><path d="M8 5.4V3.2M1.7 8.8v1.6M14.3 8.8v1.6" /><path d="M6 8.5v.1M10 8.5v.1" /></>,
puzzle: <path d="M9.8 2a1.5 1.5 0 0 1 1.5 1.5v.6h.7A1.5 1.5 0 0 1 13.5 5.6v1.9h-.6a1.5 1.5 0 0 0 0 3h.6v1.9a1.5 1.5 0 0 1-1.5 1.5h-1.9v-.6a1.5 1.5 0 0 0-3 0v.6H5.2a1.5 1.5 0 0 1-1.5-1.5v-1.9H3a1.5 1.5 0 0 1 0-3h.7V5.6A1.5 1.5 0 0 1 5.2 4.1h3.1v-.6A1.5 1.5 0 0 1 9.8 2Z" />,
grid: <><rect x="2" y="2" width="5" height="5" rx="1" /><rect x="9" y="2" width="5" height="5" rx="1" /><rect x="2" y="9" width="5" height="5" rx="1" /><rect x="9" y="9" width="5" height="5" rx="1" /></>,
'arrow-up': <path d="M8 13V3M3.5 7.5 8 3l4.5 4.5" />,
paperclip: <path d="m13.1 7.7-5 5a3.7 3.7 0 0 1-5.2-5.2l5.4-5.4a2.4 2.4 0 0 1 3.4 3.4L6.3 10.9a1.2 1.2 0 0 1-1.7-1.7l4.9-4.9" />,
more: <><circle cx="3.2" cy="8" r="1.1" fill="currentColor" stroke="none" /><circle cx="8" cy="8" r="1.1" fill="currentColor" stroke="none" /><circle cx="12.8" cy="8" r="1.1" fill="currentColor" stroke="none" /></>,
logout: <><path d="M6.2 13.5H3.5a1.5 1.5 0 0 1-1.5-1.5V4a1.5 1.5 0 0 1 1.5-1.5h2.7" /><path d="m9.8 10.7 2.7-2.7-2.7-2.7M12.5 8H6" /></>,
key: <><circle cx="5.6" cy="10.4" r="3" /><path d="m7.8 8.2 5-5M11 5l1.6 1.6M9.4 6.6 11 8.2" /></>,
user: <><circle cx="8" cy="5.4" r="2.9" /><path d="M2.7 13.8a5.5 5.5 0 0 1 10.6 0" /></>,
users: <><circle cx="6.5" cy="5.4" r="2.7" /><path d="M1.8 13.6a4.8 4.8 0 0 1 9.4 0M11 3a2.7 2.7 0 0 1 0 5.2M12.4 10.1a4.8 4.8 0 0 1 1.9 3.5" /></>,
shield: <path d="M8 1.8 13 3.6v4.2c0 3.2-2.2 5.4-5 6.4-2.8-1-5-3.2-5-6.4V3.6Z" />,
x: <path d="m4 4 8 8M12 4l-8 8" />,
check: <path d="m3.2 8.4 3.2 3.2 6.4-6.4" />,
alert: <><path d="M8 2 14.3 13.3H1.7Z" /><path d="M8 6.4v3M8 11.4v.1" /></>,
send: <path d="M14.3 1.8 1.8 7.1l4.9 1.8 1.8 4.9Z M14.3 1.8 6.7 8.9" />,
sparkles: <><path d="m11.3 1.8.9 2.3 2.3.9-2.3.9-.9 2.3-.9-2.3-2.3-.9 2.3-.9Z" /><path d="M5 6.5 5.7 8.3 7.5 9 5.7 9.7 5 11.5 4.3 9.7 2.5 9 4.3 8.3ZM8.4 10.8l.5 1.3 1.3.5-1.3.5-.5 1.3-.5-1.3L6.6 12.6l1.3-.5Z" /></>,
clock: <><circle cx="8" cy="8" r="6" /><path d="M8 4.7V8l2.3 1.4" /></>,
list: <path d="M5.5 4h8M5.5 8h8M5.5 12h8M2.6 4v.1M2.6 8v.1M2.6 12v.1" />,
}
export function Icon({ name, size = 16, ...rest }: { name: IconName; size?: number } & SVGProps<SVGSVGElement>) {
return (
<svg
width={size} height={size} viewBox="0 0 16 16" fill="none"
stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"
aria-hidden="true" {...rest}
>
{PATHS[name]}
</svg>
)
}
type IconButtonProps = {
name: IconName; size?: number; label: string
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title'>
/** 通用图标按钮:尺寸/圆角/悬停统一走 .icon-btn 样式。 */
export function IconButton({ name, size = 16, label, className = '', ...rest }: IconButtonProps) {
return (
<button className={`icon-btn ${className}`} type="button" aria-label={label} title={label} {...rest}>
<Icon name={name} size={size} />
</button>
)
}
/** 品牌标(侧栏/登录页共用):星芒 + 渐变底。 */
export function BrandMark({ size = 22 }: { size?: number }) {
return (
<span className="brand-mark" style={{ width: size, height: size, borderRadius: Math.round(size * 0.3) }}>
<Icon name="sparkles" size={Math.round(size * 0.62)} />
</span>
)
}
/** 工作区条目历史 emoji 字符串 → 线性图标。 */
export function workspaceIcon(char: string | undefined): IconName {
switch (char) {
case '📦': return 'package'
case '🤖': return 'bot'
case '🧩': return 'puzzle'
case '🗂': return 'grid'
default: return 'grid'
}
}
+10
View File
@@ -40,6 +40,7 @@ export interface UIState {
lastProjectId: string | null
adminUsersOpen: boolean
changePwOpen: boolean
projectSettingsId: string | null
}
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }
@@ -86,6 +87,7 @@ const DEFAULT_UI: UIState = {
lastProjectId: null,
adminUsersOpen: false,
changePwOpen: false,
projectSettingsId: null,
}
function loadUI(): UIState {
@@ -209,6 +211,13 @@ export const store = {
store.refreshTasks()
},
// 跨会话标记(提案 2026-09-30-cross-session-reading):出站 allowCrossRead / 入站 crossPrivate。
async setCrossFlags(convId: string, flags: { allowCrossRead?: boolean; crossPrivate?: boolean }) {
const tasks = state.tasks.map(t => t.id === convId ? { ...t, ...flags } : t)
set({ tasks })
try { await api.setConversationFlags(convId, flags) } catch (e) { store.toast(`跨会话设置失败:${e}`); store.refreshTasks() }
},
async openConversation(convId: string) {
if (sse) { sse.close(); sse = null }
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [] })
@@ -494,6 +503,7 @@ export const store = {
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
viewArtifact(a: Artifact | null) { setUI({ viewingArtifact: a }) },
openSettings(open: boolean) { setUI({ settingsOpen: open }) },
openProjectSettings(id: string | null) { setUI({ projectSettingsId: id }) },
toast(msg: string) {
set({ toast: msg })
setTimeout(() => { if (state.toast === msg) set({ toast: null }) }, 2600)
+6
View File
@@ -360,3 +360,9 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.icon-only button { background: none; border: none; color: var(--text-faint); cursor: pointer; padding: 0; width: 34px; height: 34px; border-radius: var(--radius); display: inline-flex; align-items: center; justify-content: center; position: relative; }
.icon-only button:hover { background: var(--bg-hover); color: var(--text); }
.rail-badge { position: absolute; top: 3px; right: 3px; min-width: 14px; height: 14px; border-radius: 7px; background: var(--warn); color: #fff; font-size: 9.5px; font-weight: 700; display: flex; align-items: center; justify-content: center; padding: 0 4px; }
/* ---- UI 能力平台消费(消息头像列 / 设置外壳 / 管理弹层头像) ---- */
.role-mark { width: 20px; height: 20px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); background: var(--bg-raised); border: 1px solid var(--border); }
.role-mark.role-assistant { background: none; border: none; padding: 0; }
.msg.user .msg-role { align-items: center; }
.ad-user-cell { display: flex; align-items: center; gap: 8px; }
+4 -1
View File
@@ -16,7 +16,10 @@
"baseUrl": ".",
"paths": {
"@ui": ["../ui/src/index.ts"],
"@ui/*": ["../ui/src/*"]
"@ui/*": ["../ui/src/*"],
"react": ["./node_modules/@types/react"],
"react-dom": ["./node_modules/@types/react-dom"],
"react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime"]
}
},
"include": ["src", "test"]
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/icons.tsx","./src/main.tsx","./src/passwords.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./src/passwords.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/ProjectSettingsModal.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"}