chore: scripts 与 preview 调试工具移出版本跟踪(.gitignore 忽略)
This commit is contained in:
@@ -9,3 +9,5 @@ release/
|
||||
preview-shots/
|
||||
*.tsbuildinfo
|
||||
capture-log.txt
|
||||
scripts/
|
||||
preview/
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>beautifului primitives preview</title>
|
||||
</head>
|
||||
<body class="bg-zinc-50 text-zinc-900">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,210 +0,0 @@
|
||||
// beautifului.dev 组件视觉验收页(静态构建,模拟聊天流内的真实用法)
|
||||
import { useRef, useState } from 'react'
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './preview.css'
|
||||
import { Thinking } from '../src/renderer/src/components/Thinking'
|
||||
import { StreamingText } from '../src/renderer/src/components/StreamingText'
|
||||
import { ApprovalStack } from '../src/renderer/src/components/ApprovalCard'
|
||||
import { ToolChips } from '../src/renderer/src/components/ToolChips'
|
||||
import { SidebarNav } from '../src/renderer/src/components/SidebarNav'
|
||||
import { Search } from '../src/renderer/src/components/Search'
|
||||
import { CodeBlock } from '../src/renderer/src/components/CodeBlock'
|
||||
import { SelectionActions } from '../src/renderer/src/components/SelectionActions'
|
||||
import { Loading } from '../src/renderer/src/components/Loading'
|
||||
import { Markdown } from '../src/renderer/src/components/Markdown'
|
||||
import type { ApprovalRequest, SessionMeta } from '../src/shared/types'
|
||||
|
||||
const thinkingRows = [
|
||||
{ primary: '用户想了解今天的品类热点,我先查一下最近的趋势数据。' },
|
||||
{ primary: '需要调用 query_trend 工具获取近 7 天的热点词,再按类目分组。' },
|
||||
{ primary: '结果里先给 TOP3 品类,附上建议的标题关键词。' }
|
||||
]
|
||||
|
||||
const toolCalls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'query_trend',
|
||||
tool: {
|
||||
toolName: 'query_trend',
|
||||
args: { category: 'home', days: 7 },
|
||||
status: 'done' as const,
|
||||
result: { content: [{ type: 'text', text: '{ "top": ["收纳", "香薰", "桌布"], "lift": [0.32, 0.21, 0.18] }' }] }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'call-2',
|
||||
name: 'draft_title',
|
||||
tool: {
|
||||
toolName: 'draft_title',
|
||||
args: { keywords: ['收纳', '灵感'] },
|
||||
status: 'error' as const,
|
||||
result: { content: [{ type: 'text', text: 'Error: rate limited, retry after 30s' }] }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'call-3',
|
||||
name: 'update_price',
|
||||
pendingApproval: { args: { sku: 'SKU-1024', from: 39.9, to: 35.9 } }
|
||||
}
|
||||
]
|
||||
|
||||
const approvalRequest: ApprovalRequest = {
|
||||
id: 'approval-1',
|
||||
toolName: 'update_price',
|
||||
args: { sku: 'SKU-1024', from: 39.9, to: 35.9 }
|
||||
}
|
||||
|
||||
const sessions: SessionMeta[] = [
|
||||
{ id: 's1', path: 'a.json', name: '供应商记录整理', firstMessage: '帮我整理供应商', created: '', modified: new Date().toISOString(), messageCount: 12 },
|
||||
{ id: 's2', path: 'b.json', name: '', firstMessage: '根据热点词起草一个商品标题方案', created: '', modified: new Date(Date.now() - 86400000).toISOString(), messageCount: 5 },
|
||||
{ id: 's3', path: 'c.json', name: '', firstMessage: '查询最近一次上架任务的执行结果', created: '', modified: new Date(Date.now() - 3 * 86400000).toISOString(), messageCount: 8 }
|
||||
]
|
||||
|
||||
const CODE_SNIPPET = `export async function churnBatch() {
|
||||
const flavor = await getFlavor("pistachio");
|
||||
const base = await dairy.fetch({ flavor });
|
||||
await freezer.store(base, { temp: "-16C" });
|
||||
if (!base.approved) return null;
|
||||
return base.gallons;
|
||||
}`
|
||||
|
||||
const DIFF_SNIPPET = `@@ -1,4 +1,5 @@
|
||||
export const hero = "mint-chip";
|
||||
-const windows = slots;
|
||||
+const windows = slots.filter(
|
||||
+ (s) => s.temp <= -12,
|
||||
+);
|
||||
schedule(windows);`
|
||||
|
||||
const PARA =
|
||||
'百香果风味在整个周末保持热销榜首。周六一早就要开始搅拌,让批次在下午高峰前有足够时间固化。同时需要预留两个晚间冷冻窗口,避免与常规生产冲突。'
|
||||
|
||||
function PreviewCard({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="mb-8 rounded-2xl border border-zinc-200 bg-white p-5">
|
||||
<h2 className="mb-3 text-[12px] font-medium tracking-wide text-zinc-400 uppercase">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [applied, setApplied] = useState('')
|
||||
const selectionBoxRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 调试分区:URL hash 指定只渲染某一节(#thinking / #streaming / #tools /
|
||||
// #approval / #code / #selection / #loading / #sidebar / #none),空 hash 全渲染
|
||||
const only = window.location.hash.slice(1)
|
||||
const show = (id: string): boolean => only === '' || only === id
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-6 py-8">
|
||||
<h1 className="mb-6 text-[14px] font-semibold text-zinc-900">beautifului.dev · 8 组件直连验收(light)</h1>
|
||||
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_240px] items-start gap-6">
|
||||
<div className="min-w-0">
|
||||
{show('thinking') && (
|
||||
<PreviewCard title="Thinking · 流式中 / 已完成">
|
||||
<Thinking rows={thinkingRows} streaming />
|
||||
<div className="mt-4">
|
||||
<Thinking rows={thinkingRows} streaming={false} />
|
||||
</div>
|
||||
</PreviewCard>
|
||||
)}
|
||||
|
||||
{show('streaming') && (
|
||||
<PreviewCard title="Streaming Text · 流式正文 + Follow-ups">
|
||||
<StreamingText streaming={false} text="已完成的回答文本。" followUps={['冬季哪些口味卖得最好', '对比凝胶和软冰的毛利']} onFollowUp={(q) => setApplied(q)}>
|
||||
<Markdown text={'夏季核果风味需求激增——**桃子和杏子**领先。建议先盘点蛋筒库存,再推广华夫碗套餐。'} />
|
||||
</StreamingText>
|
||||
</PreviewCard>
|
||||
)}
|
||||
|
||||
{show('tools') && (
|
||||
<PreviewCard title="Tool Chips · 真实工具调用(含待审批行)">
|
||||
<div className="w-fit max-w-full rounded-card bg-inset p-3">
|
||||
<ToolChips calls={toolCalls} thinking={['先查近 7 天热点词,再按类目分组给出 TOP3。']} />
|
||||
</div>
|
||||
</PreviewCard>
|
||||
)}
|
||||
|
||||
{show('approval') && (
|
||||
<PreviewCard title="Approval Card · 单题向导(允许/拒绝 + 倒计时)">
|
||||
<ApprovalStack requests={[approvalRequest]} onResolve={(_id, approved) => setApplied(approved ? '已允许' : '已拒绝')} />
|
||||
<div className="h-2" />
|
||||
</PreviewCard>
|
||||
)}
|
||||
|
||||
{show('code') && (
|
||||
<PreviewCard title="Code Block · 行号代码 / unified diff">
|
||||
<CodeBlock code={CODE_SNIPPET} lang="ts" filename="churn.ts" />
|
||||
<div className="mt-4">
|
||||
<CodeBlock code={DIFF_SNIPPET} lang="diff" />
|
||||
</div>
|
||||
</PreviewCard>
|
||||
)}
|
||||
|
||||
{show('selection') && (
|
||||
<PreviewCard title="Selection Actions · 划选文本试试">
|
||||
<div ref={selectionBoxRef} className="relative pb-10">
|
||||
<p className="text-[13px] leading-relaxed text-ink">{PARA}</p>
|
||||
<SelectionActions containerRef={selectionBoxRef} onApply={(instruction, selection) => setApplied(`${instruction}:${selection.slice(0, 18)}…`)} />
|
||||
</div>
|
||||
{applied && <p className="mt-2 font-mono text-[11px] text-zinc-500">onApply → {applied}</p>}
|
||||
</PreviewCard>
|
||||
)}
|
||||
|
||||
{show('loading') && (
|
||||
<PreviewCard title="Loading(附带)">
|
||||
<Loading label="思考中" variant="Dots" />
|
||||
</PreviewCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{show('sidebar') && (
|
||||
<div className="sticky top-8">
|
||||
<PreviewCard title="Sidebar Nav · 可折叠">
|
||||
<div className="h-[560px] overflow-hidden rounded-xl border border-zinc-200">
|
||||
<SidebarNav
|
||||
sessionState={{ sessionId: 's1', streaming: false, activeTools: [], messageCount: 12 }}
|
||||
sessions={sessions}
|
||||
workspaces={[{ name: 'Default', path: 'C:/workspaces/Default' }]}
|
||||
activeWorkspace={{ name: 'Default', path: 'C:/workspaces/Default' }}
|
||||
onCreateWorkspace={() => undefined}
|
||||
onOpenWorkspace={() => undefined}
|
||||
onNewSession={() => setApplied('新会话')}
|
||||
onOpenSession={() => setApplied('打开会话')}
|
||||
onRenameSession={() => setApplied('重命名')}
|
||||
onDeleteSession={() => setApplied('删除')}
|
||||
onRefreshSessions={() => setApplied('刷新')}
|
||||
onOpenSearch={() => setSearchOpen(true)}
|
||||
onOpenSettings={() => setApplied('设置')}
|
||||
/>
|
||||
</div>
|
||||
</PreviewCard>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{show('sidebar') && (
|
||||
<Search
|
||||
open={searchOpen}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
sessions={sessions}
|
||||
onNewSession={() => setApplied('新会话')}
|
||||
onRefreshSessions={() => setApplied('刷新')}
|
||||
onOpenSettings={() => setApplied('设置')}
|
||||
onOpenSession={() => setApplied('打开会话')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -1,6 +0,0 @@
|
||||
/* 预览页专用入口:复用应用全套样式(tailwind + beautifului foundation + 字体),
|
||||
* 并显式声明组件源码目录参与工具类扫描(vite root 在 preview/ 下,
|
||||
* 自动检测覆盖不到 ../src)。 */
|
||||
@import "../src/renderer/src/styles/globals.css";
|
||||
|
||||
@source "../src/renderer/src";
|
||||
@@ -1,263 +0,0 @@
|
||||
// 真实 App 渲染层截图(离屏,注入假 IPC 数据):
|
||||
// npx electron scripts/capture-app.cjs
|
||||
// 产出 preview-shots/app-*.png:展开态 / 收起态 / markdown 渲染
|
||||
const { app, BrowserWindow, ipcMain } = require('electron')
|
||||
const { mkdirSync, writeFileSync, existsSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
|
||||
app.commandLine.appendSwitch('disable-gpu')
|
||||
|
||||
const ROOT = join(__dirname, '..')
|
||||
const OUT = join(ROOT, 'preview-shots')
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
const MARKDOWN_SAMPLE = [
|
||||
'## 1. "这是演示数据,仅用于验证链路"',
|
||||
'',
|
||||
'我配置的 `hotword_lookup` 工具目前连的是**演示实现(demo)**,[项目主页](https://example.com)。',
|
||||
'',
|
||||
'| 说明 | 结论 |',
|
||||
'| :--- | ---: |',
|
||||
'| **工具可用** | 工具这个"壳"是好的 —— 链路没有问题 |',
|
||||
'| **数据源是空的** | 它背后的"仓库"是空的,查询返回"无匹配项" |',
|
||||
'',
|
||||
'- 列表项一,包含 `inline code`',
|
||||
'- 列表项二,**加粗** 与 ~~删除线~~',
|
||||
'',
|
||||
'```python',
|
||||
"print('Hello llm-ui!')",
|
||||
'for i in range(3):',
|
||||
' print(i)',
|
||||
'```',
|
||||
'',
|
||||
'```rust',
|
||||
'fn main() {',
|
||||
' println!("Hello, World!");',
|
||||
'}',
|
||||
'```'
|
||||
].join('\n')
|
||||
|
||||
const FAKE_STATE = {
|
||||
sessionId: 's1',
|
||||
sessionName: 'hi',
|
||||
streaming: false,
|
||||
activeTools: [],
|
||||
messageCount: 2,
|
||||
workspace: 'C:\\fake\\workspace'
|
||||
}
|
||||
|
||||
const FAKE_SESSIONS = [
|
||||
{ id: 's1', path: 'C:\\fake\\s1.jsonl', name: 'hi', firstMessage: '帮我写一个hello world', created: '2026-01-01', modified: '2026-01-02', messageCount: 2 },
|
||||
{ id: 's2', path: 'C:\\fake\\s2.jsonl', name: 'hello world', firstMessage: '帮我写一个hello world', created: '2026-01-01', modified: '2026-01-02', messageCount: 1 }
|
||||
]
|
||||
|
||||
function registerFakeIpc() {
|
||||
ipcMain.handle('agent:state', () => FAKE_STATE)
|
||||
ipcMain.handle('sessions:list', () => FAKE_SESSIONS)
|
||||
ipcMain.handle('workspaces:list', () => [{ name: 'Default', path: 'C:\\fake\\workspace' }])
|
||||
ipcMain.handle('models:catalog', () => ({ providers: [] }))
|
||||
ipcMain.handle('settings:get', () => ({ systemPrompt: '' }))
|
||||
ipcMain.handle('app:versions', () => ({ app: 'dev', electron: 'x', node: 'x', chrome: 'x', user: 'x' }))
|
||||
ipcMain.handle('agent:send', () => undefined)
|
||||
ipcMain.handle('chat:resources', () => ({
|
||||
commands: [{ name: 'daily-report', description: '生成每日经营日报', kind: 'prompt' }],
|
||||
skills: [{ name: 'hotword-analysis', description: '分析近 7 天类目热点词' }]
|
||||
}))
|
||||
ipcMain.handle('files:list', () => [
|
||||
{ path: 'data/products.csv', name: 'products.csv', isDir: false },
|
||||
{ path: 'docs/sop.md', name: 'sop.md', isDir: false },
|
||||
{ path: 'hello_world.py', name: 'hello_world.py', isDir: false },
|
||||
{ path: 'assets', name: 'assets', isDir: true }
|
||||
])
|
||||
}
|
||||
|
||||
async function capture(win, name) {
|
||||
// 离屏窗口 capturePage 常给旧帧:连拍多张,取最后一张(大概率已提交新帧)
|
||||
let image = null
|
||||
for (let k = 0; k < 4; k += 1) {
|
||||
win.webContents.invalidate()
|
||||
await sleep(320)
|
||||
image = await win.webContents.capturePage()
|
||||
}
|
||||
writeFileSync(join(OUT, name), image.toPNG())
|
||||
console.log('saved', name, `${image.getSize().width}x${image.getSize().height}`)
|
||||
}
|
||||
|
||||
async function run() {
|
||||
mkdirSync(OUT, { recursive: true })
|
||||
registerFakeIpc()
|
||||
|
||||
const preload = join(ROOT, 'out', 'preload', 'index.cjs')
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 900,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
paintWhenInitiallyHidden: true,
|
||||
backgroundThrottling: false,
|
||||
preload: existsSync(preload) ? preload : undefined,
|
||||
contextIsolation: true
|
||||
}
|
||||
})
|
||||
await win.loadFile(join(ROOT, 'out', 'renderer', 'index.html'))
|
||||
// 清掉历史 localStorage(收起态持久化),再重载让侧栏以展开态启动
|
||||
await win.webContents.executeJavaScript('try { localStorage.clear() } catch {}')
|
||||
await win.reload()
|
||||
await sleep(1200)
|
||||
|
||||
// 注入一条含 markdown 的会话(user + assistant)
|
||||
win.webContents.send('agent:event', {
|
||||
type: 'session_switched',
|
||||
state: FAKE_STATE,
|
||||
messages: [
|
||||
{ role: 'user', content: '解释一下那两句话的意思' },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: MARKDOWN_SAMPLE }] }
|
||||
],
|
||||
activeTools: {}
|
||||
})
|
||||
await sleep(1200)
|
||||
console.log(
|
||||
'hl-diag:',
|
||||
await win.webContents.executeJavaScript(
|
||||
`(() => { const codes = [...document.querySelectorAll('code')]; return JSON.stringify({ ready: window.__shikiReady, codes: codes.map((c) => c.querySelectorAll('span[style]').length) }) })()`
|
||||
)
|
||||
)
|
||||
await capture(win, 'app-markdown.png')
|
||||
|
||||
// 注入排队消息场景(streaming + 队列两条)
|
||||
win.webContents.send('agent:event', { type: 'agent_start' })
|
||||
win.webContents.send('agent:event', {
|
||||
type: 'queue_update',
|
||||
steering: ['设置里面做成这样,分成基础设置、Agent 能力'],
|
||||
followUp: ['随后帮我补上skills、命令和附件的支持,chatbox里面/调起命令或skill,@则是引用文件,加号是附件']
|
||||
})
|
||||
await sleep(900)
|
||||
win.webContents.invalidate()
|
||||
await sleep(300)
|
||||
FAKE_STATE.permissionMode = 'confirm'
|
||||
await capture(win, 'app-queue.png')
|
||||
|
||||
// 权限模式菜单
|
||||
await win.webContents.executeJavaScript(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`)
|
||||
win.webContents.send('agent:event', { type: 'agent_end' })
|
||||
win.webContents.send('agent:event', { type: 'queue_update', steering: [], followUp: [] })
|
||||
await sleep(500)
|
||||
await win.webContents.executeJavaScript(
|
||||
`[...document.querySelectorAll('button')].find((b) => b.title.startsWith('权限模式'))?.click()`
|
||||
)
|
||||
await sleep(500)
|
||||
win.webContents.invalidate()
|
||||
await sleep(300)
|
||||
console.log(
|
||||
'perm-diag:',
|
||||
await win.webContents.executeJavaScript(
|
||||
`(() => { const panels = [...document.querySelectorAll('div.fixed.z-40')]; const m = panels.find((d) => d.textContent.includes('减少确认次数')); if (!m) return 'closed'; const r = m.getBoundingClientRect(); const cs = getComputedStyle(m); return JSON.stringify({ x: r.x, y: r.y, w: r.width, h: r.height, opacity: cs.opacity, display: cs.display, zIndex: cs.zIndex, buttons: m.querySelectorAll('button').length }) })()`
|
||||
)
|
||||
)
|
||||
await capture(win, 'app-permissions.png')
|
||||
// 结束流式,恢复常规状态
|
||||
win.webContents.send('agent:event', { type: 'agent_end' })
|
||||
win.webContents.send('agent:event', { type: 'queue_update', steering: [], followUp: [] })
|
||||
await sleep(400)
|
||||
|
||||
// "/" 命令/技能面板
|
||||
const typeInPrompt = (text) =>
|
||||
win.webContents.executeJavaScript(
|
||||
`(() => { const ta = document.querySelector('textarea[aria-label="Prompt"]'); if (!ta) return 'no-ta'; const set = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; set.call(ta, ${JSON.stringify(text)}); ta.dispatchEvent(new Event('input', { bubbles: true })); return 'ok' })()`
|
||||
)
|
||||
await typeInPrompt('/')
|
||||
await sleep(600)
|
||||
win.webContents.invalidate()
|
||||
await sleep(300)
|
||||
await capture(win, 'app-slash-menu.png')
|
||||
// "@ " 文件引用
|
||||
await typeInPrompt('帮我看看 @')
|
||||
await sleep(600)
|
||||
win.webContents.invalidate()
|
||||
await sleep(300)
|
||||
await capture(win, 'app-at-menu.png')
|
||||
await typeInPrompt('')
|
||||
|
||||
// 注入审批卡
|
||||
win.webContents.send('agent:event', {
|
||||
type: 'approval_request',
|
||||
request: {
|
||||
id: 'approval-1',
|
||||
toolName: 'write',
|
||||
args: { path: 'hello_world.py', content: 'def main():\n print("Hello world")\n\nmain()' }
|
||||
}
|
||||
})
|
||||
await sleep(800)
|
||||
win.webContents.invalidate()
|
||||
await sleep(400)
|
||||
await capture(win, 'app-approval.png')
|
||||
|
||||
// 行内操作浮层:悬停操作按钮点击后弹出(重命名 / 删除)
|
||||
await win.webContents.executeJavaScript(
|
||||
`[...document.querySelectorAll('button')].find((b) => b.title === '重命名会话')?.click()`
|
||||
)
|
||||
await sleep(500)
|
||||
win.webContents.invalidate()
|
||||
await sleep(300)
|
||||
await capture(win, 'app-row-rename.png')
|
||||
await win.webContents.executeJavaScript(
|
||||
`[...document.querySelectorAll('button')].find((b) => b.title === '删除会话')?.click()`
|
||||
)
|
||||
await sleep(500)
|
||||
win.webContents.invalidate()
|
||||
await sleep(300)
|
||||
await capture(win, 'app-row-delete.png')
|
||||
await win.webContents.executeJavaScript(`document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))`)
|
||||
|
||||
// 设置页:Ctrl+, 打开,截「模型设置」与「常规」
|
||||
await win.webContents.executeJavaScript(
|
||||
`window.dispatchEvent(new KeyboardEvent('keydown', { key: ',', ctrlKey: true, bubbles: true }))`
|
||||
)
|
||||
await sleep(600)
|
||||
win.webContents.invalidate()
|
||||
await sleep(300)
|
||||
await capture(win, 'app-settings-model.png')
|
||||
await win.webContents.executeJavaScript(
|
||||
`[...document.querySelectorAll('button')].find((b) => b.textContent.trim() === '常规')?.click()`
|
||||
)
|
||||
await sleep(400)
|
||||
win.webContents.invalidate()
|
||||
await sleep(300)
|
||||
await capture(win, 'app-settings-general.png')
|
||||
await win.webContents.executeJavaScript(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`)
|
||||
|
||||
console.log(
|
||||
'diag:',
|
||||
await win.webContents.executeJavaScript(
|
||||
`(() => { const btn = document.querySelector('button[aria-label="拒绝并关闭"]'); if (!btn) return 'missing'; const card = btn.closest('div[class*="rounded-card"]') ?? btn.parentElement; const r = card.getBoundingClientRect(); return JSON.stringify({ x: r.x, y: r.y, w: r.width, h: r.height, winH: innerHeight, opacity: getComputedStyle(card).opacity }) })()`
|
||||
)
|
||||
)
|
||||
|
||||
// 侧栏收起回归检查
|
||||
const collapsed = await win.webContents.executeJavaScript(
|
||||
`(() => { const b = document.querySelector('[aria-label="折叠侧栏"]'); if (!b) return 'no-button'; b.click(); return 'clicked' })()`
|
||||
)
|
||||
console.log('collapse:', collapsed)
|
||||
await sleep(800)
|
||||
const geo = await win.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
const aside = document.querySelector('aside[aria-label="工作区导航"]')
|
||||
const row = document.querySelector('.sidebar-row')
|
||||
const icon = row ? row.querySelector('span') : null
|
||||
if (!aside || !row || !icon) return 'missing'
|
||||
const a = aside.getBoundingClientRect()
|
||||
const i = icon.getBoundingClientRect()
|
||||
return JSON.stringify({ rail: Math.round(a.width), iconCenter: Math.round(i.left - a.left + i.width / 2) })
|
||||
})()`
|
||||
)
|
||||
console.log('collapsed-geo:', geo)
|
||||
await capture(win, 'app-collapsed.png')
|
||||
|
||||
app.exit(0)
|
||||
}
|
||||
|
||||
app.whenReady().then(run).catch((err) => {
|
||||
console.error(err)
|
||||
app.exit(1)
|
||||
})
|
||||
@@ -1,95 +0,0 @@
|
||||
// 预览页视觉验收截图(Electron 离屏捕获):
|
||||
// npx electron scripts/capture-preview.cjs
|
||||
// 产出 preview-shots/*.png:整页 / 侧栏折叠 / 搜索面板 / 选区工具条
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const { mkdirSync, writeFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
|
||||
// 离屏捕获在 GPU 合成异常时会得到空白帧,改用软件合成
|
||||
app.commandLine.appendSwitch('disable-gpu')
|
||||
|
||||
const URL = 'http://localhost:4173/'
|
||||
const OUT = join(__dirname, '..', 'preview-shots')
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
async function capture(win, name) {
|
||||
try {
|
||||
const image = await win.webContents.capturePage()
|
||||
writeFileSync(join(OUT, name), image.toPNG())
|
||||
console.log('saved', name, `${image.getSize().width}x${image.getSize().height}`)
|
||||
} catch (e) {
|
||||
console.log('capture failed', name, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function js(win, code) {
|
||||
try {
|
||||
return await win.webContents.executeJavaScript(code)
|
||||
} catch (e) {
|
||||
console.log('js failed:', e.message)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
mkdirSync(OUT, { recursive: true })
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 960,
|
||||
show: false,
|
||||
webPreferences: { paintWhenInitiallyHidden: true }
|
||||
})
|
||||
await win.loadURL(URL)
|
||||
await sleep(1500)
|
||||
|
||||
// 1) 整页(上半)
|
||||
await capture(win, '01-full.png')
|
||||
|
||||
// 2) 折叠侧栏(等宽度过渡)
|
||||
await js(win, `document.querySelector('[aria-label="折叠侧栏"]')?.click()`)
|
||||
await sleep(700)
|
||||
await capture(win, '02-sidebar-collapsed.png')
|
||||
await js(win, `document.querySelector('[aria-label="展开侧栏"]')?.click()`)
|
||||
await sleep(700)
|
||||
|
||||
// 3) 搜索面板(侧栏「搜索」行)+ 输入过滤
|
||||
await js(win, `[...document.querySelectorAll('button')].find((b) => b.textContent.trim() === '搜索')?.click()`)
|
||||
await sleep(600)
|
||||
await js(win, `(() => { const i = document.querySelector('input[aria-label="搜索会话或命令"]'); if (!i) return 'no-input'; const set = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; set.call(i, '供应'); i.dispatchEvent(new Event('input', { bubbles: true })); return 'ok' })()`)
|
||||
await sleep(400)
|
||||
await capture(win, '03-search-filtered.png')
|
||||
await js(win, `document.querySelector('input[aria-label="搜索会话或命令"]')?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`)
|
||||
await sleep(400)
|
||||
|
||||
// 4) 划选文本 → Selection Actions 工具条(展开「更多」)
|
||||
await js(win, `(() => {
|
||||
const p = [...document.querySelectorAll('p')].find((el) => el.textContent.includes('百香果'));
|
||||
if (!p) return 'no-p';
|
||||
const textNode = p.firstChild;
|
||||
const range = document.createRange();
|
||||
range.setStart(textNode, 0);
|
||||
range.setEnd(textNode, 30);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||
return 'ok';
|
||||
})()`)
|
||||
await sleep(600)
|
||||
await js(win, `document.querySelector('[aria-label="更多操作"]')?.click()`)
|
||||
await sleep(500)
|
||||
await capture(win, '04-selection-actions.png')
|
||||
|
||||
// 5) 审批卡交互:选择「允许执行」(radio 自动前进 480ms 后提交)
|
||||
await js(win, `[...document.querySelectorAll('button')].find((b) => b.textContent.trim() === '允许执行')?.click()`)
|
||||
await sleep(900)
|
||||
await capture(win, '05-approval-approved.png')
|
||||
|
||||
app.exit(0)
|
||||
}
|
||||
|
||||
app.whenReady().then(run).catch((err) => {
|
||||
console.error(err)
|
||||
app.exit(1)
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
// 验证侧栏折叠交互
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
app.commandLine.appendSwitch('disable-gpu')
|
||||
app.commandLine.appendSwitch('disable-software-rasterizer')
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({ width: 1280, height: 900, show: false, webPreferences: { offscreen: true } })
|
||||
await win.loadURL('http://127.0.0.1:4173/')
|
||||
await new Promise((r) => setTimeout(r, 1200))
|
||||
const found = await win.webContents.executeJavaScript(`document.querySelector('[aria-label="折叠侧栏"]') ? 'found' : 'NOT-FOUND'`)
|
||||
console.log('collapse btn:', found)
|
||||
await win.webContents.executeJavaScript(`document.querySelector('[aria-label="折叠侧栏"]').click()`)
|
||||
await new Promise((r) => setTimeout(r, 600))
|
||||
console.log(
|
||||
'after click: collapsed=',
|
||||
await win.webContents.executeJavaScript(`document.querySelector('aside')?.getAttribute('data-sidebar-collapsed')`),
|
||||
'width=',
|
||||
await win.webContents.executeJavaScript(`document.querySelector('aside')?.style.width`)
|
||||
)
|
||||
app.exit(0)
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
// 预览页控制台诊断:node 调试某一分区
|
||||
// npx electron scripts/debug-preview.cjs "#thinking"
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
|
||||
const hash = process.argv[2] ?? ''
|
||||
const URL = `http://127.0.0.1:4173/${hash}`
|
||||
|
||||
// 兜底退出(渲染进程卡死时 executeJavaScript 永不返回)
|
||||
const guard = setTimeout(() => {
|
||||
console.log('[guard] hard exit (page busy or crashed)')
|
||||
app.exit(2)
|
||||
}, 12000)
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 900,
|
||||
show: false,
|
||||
webPreferences: { offscreen: true }
|
||||
})
|
||||
win.webContents.on('console-message', (_e, level, message, line, source) => {
|
||||
console.log(`[console:${level}]`, message, `(${source}:${line})`)
|
||||
})
|
||||
win.webContents.on('did-fail-load', (_e, code, desc, url) => {
|
||||
console.log(`[did-fail-load] ${code} ${desc} ${url}`)
|
||||
})
|
||||
win.webContents.on('render-process-gone', (_e, details) => {
|
||||
console.log('[crashed]', details.reason)
|
||||
})
|
||||
await win.loadURL(URL)
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const text = await win.webContents.executeJavaScript('document.body.innerText.slice(0, 120)')
|
||||
console.log('[body]', JSON.stringify(text))
|
||||
} catch (e) {
|
||||
console.log('[executeJavaScript failed]', e.message)
|
||||
}
|
||||
clearTimeout(guard)
|
||||
app.exit(0)
|
||||
}, 2500)
|
||||
})
|
||||
@@ -1,17 +0,0 @@
|
||||
// 对比 out/renderer 旧 bundle 与当前源码的 reducer 逻辑
|
||||
const fs = require('fs')
|
||||
const t = fs.readFileSync('apps/desktop/out/renderer/assets/index-DJXyfP9T.js', 'utf8')
|
||||
|
||||
console.log('has message_end:', t.includes('message_end'))
|
||||
console.log('has optimistic-user:', t.includes('optimistic-user'))
|
||||
console.log('has session_switched:', t.includes('session_switched'))
|
||||
console.log('has agent_settled:', t.includes('agent_settled'))
|
||||
console.log('has approval_request:', t.includes('approval_request'))
|
||||
|
||||
// reducer 里 message_end 附近的代码
|
||||
let i = t.indexOf('"message_end"')
|
||||
if (i < 0) i = t.indexOf('message_end')
|
||||
if (i >= 0) {
|
||||
console.log('\n=== around first message_end ===')
|
||||
console.log(t.slice(i - 500, i + 900))
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
// 生成应用图标(build/icon.ico + build/icon.png):
|
||||
// npx electron scripts/make-icons.cjs
|
||||
// 用 Electron 离屏渲染 build/icon.svg(居中于方形画布、留 9% 边距),
|
||||
// 输出多尺寸 PNG 并打包为 PNG 内嵌式 ICO(Vista+ 支持)。
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const { readFileSync, writeFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
|
||||
app.commandLine.appendSwitch('disable-gpu')
|
||||
|
||||
const SIZES = [16, 24, 32, 48, 64, 128, 256]
|
||||
const ROOT = join(__dirname, '..')
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
/** PNG 内嵌式 ICO 打包 */
|
||||
function buildIco(pngs) {
|
||||
const count = pngs.length
|
||||
const header = Buffer.alloc(6)
|
||||
header.writeUInt16LE(0, 0) // reserved
|
||||
header.writeUInt16LE(1, 2) // type: icon
|
||||
header.writeUInt16LE(count, 4)
|
||||
const entries = Buffer.alloc(16 * count)
|
||||
const blobs = []
|
||||
let offset = 6 + 16 * count
|
||||
pngs.forEach((png, i) => {
|
||||
const size = png.size
|
||||
entries.writeUInt8(size >= 256 ? 0 : size, i * 16 + 0) // width
|
||||
entries.writeUInt8(size >= 256 ? 0 : size, i * 16 + 1) // height
|
||||
entries.writeUInt8(0, i * 16 + 2) // palette
|
||||
entries.writeUInt8(0, i * 16 + 3) // reserved
|
||||
entries.writeUInt16LE(1, i * 16 + 4) // planes
|
||||
entries.writeUInt16LE(32, i * 16 + 6) // bpp
|
||||
entries.writeUInt32LE(png.data.length, i * 16 + 8)
|
||||
entries.writeUInt32LE(offset, i * 16 + 12)
|
||||
offset += png.data.length
|
||||
blobs.push(png.data)
|
||||
})
|
||||
return Buffer.concat([header, entries, ...blobs])
|
||||
}
|
||||
|
||||
async function renderSize(win, svgDataUrl, size) {
|
||||
await win.loadURL(
|
||||
'data:text/html,' +
|
||||
encodeURIComponent(
|
||||
`<!doctype html><html><body style="margin:0;background:transparent">` +
|
||||
`<div style="display:flex;align-items:center;justify-content:center;width:${size}px;height:${size}px">` +
|
||||
`<img src="${svgDataUrl}" style="width:${Math.round(size * 0.86)}px;height:auto;display:block"/>` +
|
||||
`</div></body></html>`
|
||||
)
|
||||
)
|
||||
await sleep(350)
|
||||
const image = await win.webContents.capturePage({ x: 0, y: 0, width: size, height: size })
|
||||
return image.toPNG()
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const svg = readFileSync(join(ROOT, 'build', 'icon.svg'), 'utf8')
|
||||
const svgDataUrl = 'data:image/svg+xml;base64,' + Buffer.from(svg, 'utf8').toString('base64')
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 256,
|
||||
height: 256,
|
||||
show: false,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
webPreferences: { paintWhenInitiallyHidden: true, offscreen: true }
|
||||
})
|
||||
|
||||
const pngs = []
|
||||
for (const size of SIZES) {
|
||||
await win.setSize ? win.setSize(size, size) : null
|
||||
const png = await renderSize(win, svgDataUrl, size)
|
||||
pngs.push({ size, data: png })
|
||||
console.log('rendered', size, `${png.length}B`)
|
||||
}
|
||||
|
||||
writeFileSync(join(ROOT, 'build', 'icon.png'), pngs.find((p) => p.size === 256).data)
|
||||
writeFileSync(join(ROOT, 'build', 'icon.ico'), buildIco(pngs))
|
||||
console.log('written build/icon.ico & build/icon.png')
|
||||
app.exit(0)
|
||||
}
|
||||
|
||||
app.whenReady().then(run).catch((err) => {
|
||||
console.error(err)
|
||||
app.exit(1)
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
// 打印 pi SDK 关键类型定义(skills / slash-commands / prompt 参数)
|
||||
const fs = require('node:fs')
|
||||
const files = [
|
||||
'dist/core/skills.d.ts',
|
||||
'dist/core/slash-commands.d.ts',
|
||||
'dist/modes/interactive/components/skill-invocation-message.d.ts'
|
||||
]
|
||||
for (const f of files) {
|
||||
console.log('=====', f)
|
||||
try {
|
||||
console.log(fs.readFileSync(f, 'utf8'))
|
||||
} catch (e) {
|
||||
console.log('READ FAIL', e.message)
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// 打印 ImageContent / PromptTemplate / AgentSessionServices 类型
|
||||
const fs = require('node:fs')
|
||||
const root = 'C:/Users/21976/Desktop/Develop/agent-store-operations/apps/desktop/node_modules'
|
||||
const files = [
|
||||
[root + '/@earendil-works/pi-ai/dist/compat.d.ts', /export (interface|type) (ImageContent|TextContent)[\s\S]*?\n\}/],
|
||||
[root + '/@earendil-works/pi-coding-agent/dist/core/prompt-templates.d.ts', null],
|
||||
[root + '/@earendil-works/pi-coding-agent/dist/core/sdk.d.ts', /export interface AgentSessionServices[\s\S]*?\n\}/]
|
||||
]
|
||||
for (const [f, re] of files) {
|
||||
console.log('=====', f.split('/').slice(-2).join('/'))
|
||||
try {
|
||||
const s = fs.readFileSync(f, 'utf8')
|
||||
if (re) {
|
||||
const m = s.match(re)
|
||||
console.log(m ? m[0] : '(no match)')
|
||||
} else {
|
||||
console.log(s.slice(0, 2500))
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('READ FAIL', e.message)
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// 验证 pi SDK 对空目录(无会话的工作区)的行为:
|
||||
// node scripts/repro-empty-workspace.mjs "C:\path\to\empty\dir"
|
||||
import { SessionManager, getAgentDir } from '@earendil-works/pi-coding-agent'
|
||||
import { mkdirSync, mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const target = process.argv[2] ?? mkdtempSync(join(tmpdir(), 'ws-empty-'))
|
||||
mkdirSync(target, { recursive: true })
|
||||
console.log('target cwd:', target)
|
||||
|
||||
try {
|
||||
const sm = SessionManager.continueRecent(target)
|
||||
console.log('continueRecent OK, sessionFile:', sm.sessionFile ?? '(none)')
|
||||
const list = await SessionManager.list(target)
|
||||
console.log('list count:', list.length)
|
||||
} catch (err) {
|
||||
console.error('continueRecent FAILED:', err instanceof Error ? err.message : err)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('agentDir:', getAgentDir())
|
||||
@@ -1,103 +0,0 @@
|
||||
// 复现渲染层"输出一闪而过":用与 agent-service.ts 相同的 SDK 调用链,
|
||||
// 订阅全部事件并打印事件序列与消息对象结构。
|
||||
import os from 'node:os'
|
||||
import {
|
||||
createAgentSessionRuntime,
|
||||
createAgentSessionServices,
|
||||
createAgentSessionFromServices,
|
||||
getAgentDir,
|
||||
SessionManager
|
||||
} from '@earendil-works/pi-coding-agent'
|
||||
|
||||
const cwd = os.homedir()
|
||||
const agentDir = getAgentDir()
|
||||
|
||||
const services = await createAgentSessionServices({ cwd, agentDir })
|
||||
const factory = async (opts) => {
|
||||
const result = await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager: opts.sessionManager,
|
||||
sessionStartEvent: opts.sessionStartEvent
|
||||
})
|
||||
return { ...result, services, diagnostics: services.diagnostics }
|
||||
}
|
||||
|
||||
const sessionManager = SessionManager.continueRecent(cwd)
|
||||
const runtime = await createAgentSessionRuntime(factory, { cwd, agentDir, sessionManager })
|
||||
const session = runtime.session
|
||||
|
||||
let rebindCount = 0
|
||||
runtime.setRebindSession(async (s) => {
|
||||
rebindCount++
|
||||
console.log(`\n>>> REBIND fired (#${rebindCount}) — 渲染层会收到 session_switched,messages.length=${s.messages.length}`)
|
||||
})
|
||||
|
||||
session.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case 'message_start':
|
||||
console.log('EVT message_start role=', JSON.stringify(event.message?.role))
|
||||
break
|
||||
case 'message_update': {
|
||||
const inner = event.assistantMessageEvent
|
||||
if (inner) {
|
||||
if (inner.type.endsWith('_delta')) {
|
||||
process.stdout.write(`.`)
|
||||
} else {
|
||||
console.log(`EVT message_update ${inner.type}`)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'message_end': {
|
||||
const m = event.message
|
||||
console.log('\nEVT message_end role=', m?.role)
|
||||
console.log(' message keys:', Object.keys(m ?? {}))
|
||||
try {
|
||||
JSON.stringify(m)
|
||||
console.log(' JSON.stringify: OK, length=', JSON.stringify(m).length)
|
||||
} catch (err) {
|
||||
console.log(' JSON.stringify FAILED ->', err.message, '(toCloneable 会变成 null → 渲染层消息变空!)')
|
||||
}
|
||||
if (m?.role === 'assistant') {
|
||||
console.log(' stopReason=', m.stopReason, ' errorMessage=', m.errorMessage)
|
||||
console.log(' content types=', (m.content ?? []).map((b) => b.type).join(','))
|
||||
for (const b of m.content ?? []) {
|
||||
if (b.type === 'text') console.log(' text block:', JSON.stringify(b.text).slice(0, 200))
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'agent_end':
|
||||
console.log(`\nEVT agent_end willRetry=${event.willRetry} messages=${event.messages?.length}`)
|
||||
break
|
||||
default:
|
||||
console.log('EVT', event.type)
|
||||
}
|
||||
})
|
||||
|
||||
console.log('--- session:', session.sessionId, 'messages:', session.messages.length)
|
||||
console.log('--- model:', session.model?.provider + '/' + session.model?.id)
|
||||
console.log('--- sending "hi" ...')
|
||||
|
||||
try {
|
||||
await session.prompt('hi')
|
||||
console.log('\n--- prompt resolved')
|
||||
} catch (err) {
|
||||
console.log('\n--- prompt THREW:', err?.message)
|
||||
}
|
||||
|
||||
// 等待 agent_settled 之后的异步收尾
|
||||
await new Promise((r) => setTimeout(r, 1500))
|
||||
|
||||
console.log('\n=== final session.messages ===')
|
||||
for (const [i, m] of session.messages.entries()) {
|
||||
const blocks = Array.isArray(m.content)
|
||||
? m.content.map((b) => b.type).join(',')
|
||||
: typeof m.content === 'string'
|
||||
? 'str:' + m.content.slice(0, 30)
|
||||
: '??'
|
||||
console.log(`#${i} ${m.role} [${blocks}]`)
|
||||
}
|
||||
|
||||
console.log('\nrebindCount=', rebindCount)
|
||||
process.exit(0)
|
||||
@@ -1,41 +0,0 @@
|
||||
// 验证 Markdown.tsx 的 parseBlocks 是否产出空内容
|
||||
const INLINE = /^(\s*)([-*+]\s+|\d+[.)]\s+|>\s?|(#{1,3}\s+))?(.*)$/
|
||||
|
||||
function parseBlocks(text) {
|
||||
const blocks = []
|
||||
const lines = text.split('\n')
|
||||
let current = null
|
||||
const flush = () => {
|
||||
if (current) blocks.push(current)
|
||||
current = null
|
||||
}
|
||||
for (const line of lines) {
|
||||
const m = line.match(INLINE)
|
||||
if (!m) continue
|
||||
const [, indent, marker, content] = m
|
||||
const isListItem = Boolean(marker && /^([-*+]\s+|\d+[.)]\s+)/.test(marker))
|
||||
const kind = marker
|
||||
? marker.startsWith('#')
|
||||
? 'h1'
|
||||
: isListItem
|
||||
? /^\d/.test(marker) ? 'ol' : 'ul'
|
||||
: marker.startsWith('>') ? 'quote' : null
|
||||
: 'p'
|
||||
if (kind === 'p') {
|
||||
if (!current || current.kind !== 'p') {
|
||||
flush()
|
||||
current = { kind: 'p', lines: [] }
|
||||
}
|
||||
current.lines.push(content)
|
||||
continue
|
||||
}
|
||||
flush()
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
for (const t of ['pong', 'Hello world', '# 标题', '- 列表项', '> 引用', '1. 有序']) {
|
||||
const blocks = parseBlocks(t)
|
||||
const rendered = blocks.map((b) => b.lines.join('\n'))
|
||||
console.log(JSON.stringify(t), '->', JSON.stringify(rendered))
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
// 验证渲染层对 session_switched 历史消息的渲染逻辑:
|
||||
// 用与主进程一致的 session.messages 源,跑 MessageItem 的 readBlocks + AssistantBody 判断。
|
||||
import os from 'node:os'
|
||||
import { createAgentSessionServices, getAgentDir } from '@earendil-works/pi-coding-agent'
|
||||
|
||||
const cwd = os.homedir()
|
||||
const services = await createAgentSessionServices({ cwd, agentDir: getAgentDir() })
|
||||
|
||||
// SessionManager.list + 加载最近会话的等价做法:直接用 services 里加载当前会话
|
||||
const session = services.sessionManager
|
||||
? null
|
||||
: null
|
||||
|
||||
// 走 SessionManager 拿最近会话并加载
|
||||
const { SessionManager } = await import('@earendil-works/pi-coding-agent')
|
||||
const sm = SessionManager.continueRecent(cwd)
|
||||
const smAny = sm
|
||||
// SessionManager.continueRecent 返回的 manager 需要挂到 session 上才有 messages;
|
||||
// 更直接:读它指向的会话文件并解析 message 条目
|
||||
const entries = []
|
||||
if (typeof smAny.loadEntries === 'function') {
|
||||
entries.push(...(await smAny.loadEntries()))
|
||||
}
|
||||
console.log('loadEntries available:', typeof smAny.loadEntries === 'function', 'entries:', entries.length)
|
||||
|
||||
// 退路:直接解析磁盘上的最近 jsonl(与主进程 toCloneable 后的消息同构)
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
const dir = join(cwd, '.pi', 'agent', 'sessions', '--C--Users-21976--')
|
||||
const files = readdirSync(dir).filter((f) => f.endsWith('.jsonl'))
|
||||
files.sort((a, b) => statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs)
|
||||
const latest = files[0]
|
||||
console.log('latest session file:', latest)
|
||||
|
||||
const lines = readFileSync(join(dir, latest), 'utf8').trim().split('\n')
|
||||
const messages = []
|
||||
for (const line of lines) {
|
||||
let entry
|
||||
try {
|
||||
entry = JSON.parse(line)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (entry.type === 'message' && entry.message) messages.push(entry.message)
|
||||
}
|
||||
|
||||
// ==== 渲染层逻辑复刻(与 MessageItem.tsx 一致)====
|
||||
function readBlocks(raw) {
|
||||
if (!raw || typeof raw !== 'object') return []
|
||||
const content = raw.content
|
||||
if (typeof content === 'string') return [{ type: 'text', text: content }]
|
||||
if (Array.isArray(content)) {
|
||||
return content.filter((c) => typeof c === 'object' && c !== null)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
for (const [i, m] of messages.entries()) {
|
||||
console.log(`\n=== history msg #${i} role=${m.role} ===`)
|
||||
const blocks = readBlocks(m)
|
||||
let rendered = 0
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'thinking' && typeof block.text === 'string') {
|
||||
console.log(' -> ThinkingBlock (text field)')
|
||||
rendered++
|
||||
continue
|
||||
}
|
||||
if (block.type === 'text' && block.text) {
|
||||
console.log(` -> Markdown("${String(block.text).slice(0, 60)}…")`)
|
||||
rendered++
|
||||
continue
|
||||
}
|
||||
if (block.type === 'toolCall' && block.id) {
|
||||
console.log(' -> ToolCard', block.name)
|
||||
rendered++
|
||||
continue
|
||||
}
|
||||
console.log(' -> **被丢弃** block type =', block.type, 'keys =', Object.keys(block).join(','))
|
||||
}
|
||||
console.log(` rendered ${rendered}/${blocks.length} blocks`)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* v0.3.0 冒烟:模型目录 / API key login-logout / setModel / 系统提示词
|
||||
* 不打真实 LLM 请求;key 用假值(仅验证凭据写入与模型切换逻辑)。
|
||||
*/
|
||||
import { createAgentSessionServices, createAgentSessionRuntime, createAgentSessionFromServices, getAgentDir, SessionManager } from '@earendil-works/pi-coding-agent'
|
||||
import { homedir } from 'node:os'
|
||||
import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
const cwd = homedir()
|
||||
const agentDir = getAgentDir()
|
||||
|
||||
const services = await createAgentSessionServices({ cwd, agentDir })
|
||||
const factory = async ({ cwd: nc, agentDir: na, sessionManager }) => {
|
||||
const svc = nc === services.cwd && na === services.agentDir ? services : await createAgentSessionServices({ cwd: nc, agentDir: na })
|
||||
const result = await createAgentSessionFromServices({ services: svc, sessionManager })
|
||||
return { ...result, services: svc, diagnostics: svc.diagnostics }
|
||||
}
|
||||
const runtime = await createAgentSessionRuntime(factory, { cwd, agentDir, sessionManager: SessionManager.continueRecent(cwd) })
|
||||
const mr = runtime.session.modelRuntime
|
||||
|
||||
// 1) 模型目录
|
||||
const providers = mr.getProviders()
|
||||
console.log('[smoke3] providers:', providers.map((p) => p.id).join(', '))
|
||||
const anthropic = providers.find((p) => p.id === 'anthropic')
|
||||
if (anthropic) {
|
||||
const models = mr.getModels('anthropic')
|
||||
console.log('[smoke3] anthropic models: %d (first: %s)', models.length, models[0]?.id)
|
||||
|
||||
// 2) login(假 key)→ listCredentials
|
||||
const before = await mr.listCredentials()
|
||||
await mr.login('anthropic', 'api_key', { prompt: async () => 'sk-smoke-fake-key', notify: async () => undefined })
|
||||
const after = await mr.listCredentials()
|
||||
console.log('[smoke3] credentials before=%d after=%d (anthropic=%s)', before.length, after.length, after.some((c) => c.providerId === 'anthropic' && c.type === 'api_key'))
|
||||
|
||||
// auth.json 应包含 anthropic 条目
|
||||
const auth = JSON.parse(readFileSync(join(agentDir, 'auth.json'), 'utf8'))
|
||||
console.log('[smoke3] auth.json has anthropic:', Boolean(auth.anthropic))
|
||||
|
||||
// 3) setModel(有凭据时允许)
|
||||
const target = models[0]
|
||||
await runtime.session.setModel(target, { persist: false })
|
||||
console.log('[smoke3] setModel ok -> %s/%s', runtime.session.model?.provider, runtime.session.model?.id)
|
||||
|
||||
// 4) logout
|
||||
await mr.logout('anthropic')
|
||||
const finalCreds = await mr.listCredentials()
|
||||
console.log('[smoke3] after logout, anthropic credential present:', finalCreds.some((c) => c.providerId === 'anthropic'))
|
||||
} else {
|
||||
console.log('[smoke3] anthropic not found in providers, skip key tests')
|
||||
}
|
||||
|
||||
// 5) 系统提示词 override(独立 services,用 resourceLoaderOptions.systemPrompt)
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'aso-smoke-'))
|
||||
const promptText = '你是冒烟测试助手。'
|
||||
const svc2 = await createAgentSessionServices({ cwd, agentDir, resourceLoaderOptions: { systemPrompt: promptText } })
|
||||
console.log('[smoke3] systemPrompt applied:', svc2.resourceLoader ? 'services-created' : 'fail')
|
||||
const rt2 = await createAgentSessionFromServices({ services: svc2, sessionManager: SessionManager.inMemory(cwd) })
|
||||
console.log('[smoke3] session systemPrompt startsWith expected:', rt2.session.systemPrompt.startsWith(promptText.slice(0, 8)))
|
||||
|
||||
await runtime.dispose()
|
||||
writeFileSync(join(tmp, 'done'), '')
|
||||
console.log('[smoke3] done')
|
||||
process.exit(0)
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* pi SDK 会话管理冒烟测试(v0.2.0)
|
||||
* 验证:runtime 创建、list、newSession、switchSession(历史恢复)、rename、删除(回收站)。
|
||||
*/
|
||||
import { createAgentSessionRuntime, createAgentSessionServices, createAgentSessionFromServices, getAgentDir, SessionManager } from '@earendil-works/pi-coding-agent'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const cwd = homedir()
|
||||
const agentDir = getAgentDir()
|
||||
|
||||
console.log('[smoke2] listing sessions (cwd=%s)...', cwd)
|
||||
const before = await SessionManager.list(cwd)
|
||||
console.log('[smoke2] found %d sessions', before.length)
|
||||
|
||||
const initialServices = await createAgentSessionServices({ cwd, agentDir })
|
||||
const factory = async ({ cwd: nextCwd, agentDir: nextAgentDir, sessionManager, sessionStartEvent }) => {
|
||||
const services =
|
||||
nextCwd === initialServices.cwd && nextAgentDir === initialServices.agentDir
|
||||
? initialServices
|
||||
: await createAgentSessionServices({ cwd: nextCwd, agentDir: nextAgentDir })
|
||||
const result = await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })
|
||||
return { ...result, services, diagnostics: services.diagnostics }
|
||||
}
|
||||
|
||||
const sessionManager = SessionManager.create(cwd)
|
||||
const runtime = await createAgentSessionRuntime(factory, { cwd, agentDir, sessionManager })
|
||||
let rebindCount = 0
|
||||
runtime.setRebindSession(async () => {
|
||||
rebindCount += 1
|
||||
})
|
||||
console.log('[smoke2] runtime ready, session=%s', runtime.session.sessionId)
|
||||
|
||||
// 重命名
|
||||
runtime.session.setSessionName('smoke-测试会话')
|
||||
console.log('[smoke2] renamed to: %s', runtime.session.sessionName)
|
||||
|
||||
// 会话里追加用户+助手消息(直接写会话文件,不触发 LLM)
|
||||
// pi 约定:只有出现 assistant 消息后才会真正落盘(避免空会话残留文件)
|
||||
runtime.session.sessionManager.appendMessage({ role: 'user', content: '这是一条冒烟测试消息', timestamp: Date.now() })
|
||||
runtime.session.sessionManager.appendMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: '冒烟回复(不经过 LLM)' }],
|
||||
api: 'openai-completions',
|
||||
provider: 'smoke',
|
||||
model: 'smoke-model',
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, cost: 0 },
|
||||
stopReason: 'stop',
|
||||
timestamp: Date.now()
|
||||
})
|
||||
console.log('[smoke2] messages in session:', runtime.session.messages.length)
|
||||
|
||||
const listAfter = await SessionManager.list(cwd)
|
||||
const mine = listAfter.find((s) => s.name === 'smoke-测试会话' || s.firstMessage.includes('冒烟测试'))
|
||||
console.log('[smoke2] session visible in list: %s (total %d)', Boolean(mine), listAfter.length)
|
||||
if (mine) {
|
||||
console.log('[smoke2] meta: name=%s firstMessage=%s count=%d', mine.name, JSON.stringify(mine.firstMessage.slice(0, 30)), mine.messageCount)
|
||||
|
||||
// 新会话(切换)
|
||||
await runtime.newSession()
|
||||
console.log('[smoke2] newSession ok, rebind=%d, new id=%s, messages=%d', rebindCount, runtime.session.sessionId, runtime.session.messages.length)
|
||||
|
||||
// 切回
|
||||
await runtime.switchSession(mine.path)
|
||||
console.log(
|
||||
'[smoke2] switch back ok, rebind=%d, id=%s, messages restored=%d, name=%s',
|
||||
rebindCount,
|
||||
runtime.session.sessionId,
|
||||
runtime.session.messages.length,
|
||||
runtime.session.sessionName ?? '(none)'
|
||||
)
|
||||
}
|
||||
|
||||
await runtime.dispose()
|
||||
console.log('[smoke2] done')
|
||||
process.exit(0)
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* v0.4.0 冒烟:业务工具注册 / 手动直调(D2 双模式路径)/ tool_call 拦截契约
|
||||
* 说明:审批拦截逻辑与 src/main/pi/approval.ts 保持一致(此处内联同构实现,
|
||||
* 因冒烟脚本无法直接加载 TS 源文件;拦截契约由 pi 的 ExtensionHandler 类型保证)。
|
||||
*/
|
||||
import { createAgentSessionFromServices, createAgentSessionServices, defineTool, getAgentDir, SessionManager } from '@earendil-works/pi-coding-agent'
|
||||
import { Type } from '@earendil-works/pi-ai'
|
||||
import { homedir } from 'node:os'
|
||||
|
||||
const cwd = homedir()
|
||||
const agentDir = getAgentDir()
|
||||
|
||||
// 与 src/main/tools/demo-tools.ts 同构的工具定义
|
||||
const hotwordLookup = defineTool({
|
||||
name: 'hotword_lookup',
|
||||
label: '热点词查询(演示)',
|
||||
description: 'demo',
|
||||
parameters: Type.Object({ query: Type.String(), limit: Type.Optional(Type.Number()) }),
|
||||
async execute(_id, params) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `热点词查询结果(演示数据源,query="${params.query}"):ok` }],
|
||||
details: { source: 'demo' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ---- 1) 工具注册检查(customTools 路径) ----
|
||||
const services = await createAgentSessionServices({ cwd, agentDir })
|
||||
const { session } = await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager: SessionManager.inMemory(cwd),
|
||||
customTools: [hotwordLookup]
|
||||
})
|
||||
const all = session.getAllTools()
|
||||
console.log('[smoke4] registered tools:', all.map((t) => t.name).join(', '))
|
||||
console.log('[smoke4] hotword_lookup registered:', all.some((t) => t.name === 'hotword_lookup'))
|
||||
|
||||
// ---- 2) 手动直调(双模式共用能力的证明,不经 LLM) ----
|
||||
const result = await hotwordLookup.execute('smoke-call', { query: '宠物' }, undefined, undefined, {})
|
||||
console.log('[smoke4] manual execute text:', JSON.stringify(result.content[0]?.text?.slice(0, 60)))
|
||||
|
||||
// ---- 3) tool_call 拦截契约(模拟 ExtensionAPI 捕获 handler) ----
|
||||
const handlers = {}
|
||||
const fakePi = { on: (event, handler) => { handlers[event] = handler } }
|
||||
const needsApproval = new Set(['price_check', 'bash'])
|
||||
let decision = false
|
||||
fakePi.on('tool_call', async (event) => {
|
||||
if (!needsApproval.has(event.toolName)) return
|
||||
const approved = decision
|
||||
if (!approved) return { block: true, reason: '用户拒绝了本次工具调用审批' }
|
||||
return
|
||||
})
|
||||
|
||||
const passthrough = await handlers['tool_call']({ type: 'tool_call', toolName: 'read', input: { path: 'x' } })
|
||||
console.log('[smoke4] read(不在名单)放行:', passthrough === undefined)
|
||||
|
||||
decision = false
|
||||
const denied = await handlers['tool_call']({ type: 'tool_call', toolName: 'price_check', input: { product_id: 'P1', target_price: 9.9 } })
|
||||
console.log('[smoke4] price_check 拒绝 -> block:', JSON.stringify(denied))
|
||||
|
||||
decision = true
|
||||
const approvedRes = await handlers['tool_call']({ type: 'tool_call', toolName: 'bash', input: { command: 'ls' } })
|
||||
console.log('[smoke4] bash 批准 -> 放行:', approvedRes === undefined)
|
||||
|
||||
console.log('[smoke4] done')
|
||||
process.exit(0)
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* pi SDK 集成冒烟测试(与 src/main/pi/agent-service.ts 相同的调用路径)
|
||||
* 用法:node scripts/smoke.mjs
|
||||
* 验证点:会话创建、事件订阅、prompt 在无凭据时的报错传播。
|
||||
*/
|
||||
import { createAgentSession } from '@earendil-works/pi-coding-agent'
|
||||
import { homedir } from 'node:os'
|
||||
|
||||
const events = []
|
||||
let settled = false
|
||||
|
||||
console.log('[smoke] creating session (cwd=%s)...', homedir())
|
||||
const { session } = await createAgentSession({ cwd: homedir() })
|
||||
console.log('[smoke] session created: id=%s model=%s/%s', session.sessionId, session.model?.provider ?? '-', session.model?.id ?? '-')
|
||||
|
||||
session.subscribe((event) => {
|
||||
events.push(event.type)
|
||||
if (event.type === 'message_update' && event.assistantMessageEvent?.type?.endsWith('_delta')) {
|
||||
const d = event.assistantMessageEvent.delta ?? ''
|
||||
process.stdout.write(d)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await session.prompt('回复"链路正常"两个字即可,不要调用任何工具。')
|
||||
console.log('\n[smoke] prompt resolved; event sequence:', [...new Set(events)].join(' -> '))
|
||||
const last = session.getLastAssistantText()
|
||||
console.log('[smoke] last assistant text:', last ? JSON.stringify(last.slice(0, 120)) : '(none)')
|
||||
settled = true
|
||||
} catch (err) {
|
||||
console.log('\n[smoke] prompt rejected as expected (no credentials):', err instanceof Error ? err.message : err)
|
||||
}
|
||||
|
||||
const stats = session.getSessionStats()
|
||||
console.log('[smoke] stats:', JSON.stringify({ messages: session.messages.length, cost: stats.cost }))
|
||||
console.log('[smoke] active tools:', session.getActiveToolNames().join(', '))
|
||||
session.dispose()
|
||||
console.log('[smoke] done, settled=%s', String(settled))
|
||||
process.exit(0)
|
||||
File diff suppressed because one or more lines are too long
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "button",
|
||||
"type": "registry:ui",
|
||||
"title": "Button",
|
||||
"description": "Shared building block used by other Beautiful UI components.",
|
||||
"dependencies": [
|
||||
"class-variance-authority"
|
||||
],
|
||||
"registryDependencies": [
|
||||
"https://www.beautifului.dev/r/foundation.json"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "components/atoms/Button.tsx",
|
||||
"content": "\"use client\";\n\nimport { ButtonHTMLAttributes } from \"react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { cn } from \"@/lib/utils\";\n\nconst filledShadow = \"shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]\";\n\n/* Pill-shaped by default — the app's core button style. Explicit symmetric\n * padding (not a fixed height) so the top/bottom spacing is always equal. */\nexport const buttonVariants = cva(\n `inline-flex items-center justify-center font-medium select-none\n transition-[transform,background-color,opacity] duration-150 ease-out\n active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none`,\n {\n variants: {\n variant: {\n primary: `bg-ink text-canvas hover:opacity-90 dark:bg-ink dark:text-canvas ${filledShadow}`,\n secondary: \"bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover\",\n ghost: \"bg-hover-2 text-ink hover:bg-line-strong\",\n accent: `bg-accent text-white hover:bg-accent-ink ${filledShadow}`,\n success: `bg-green text-white hover:brightness-95 ${filledShadow}`,\n /* transparent until hovered — for dense toolbars/action rows */\n quiet: \"text-ink hover:bg-hover\",\n },\n size: {\n /* compact toolbar pill — fixed height, lighter weight */\n xs: \"h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1\",\n /* canonical action pill — 27px tall, roomy sides */\n sm: \"h-[27px] px-3 text-[13px] leading-none rounded-full gap-1.5\",\n md: \"px-4 py-[9px] text-sm leading-none rounded-full gap-2\",\n },\n },\n defaultVariants: { variant: \"secondary\", size: \"md\" },\n },\n);\n\nexport type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>[\"variant\"]>;\n\nexport function Button({\n variant,\n size,\n className,\n ...props\n}: ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>) {\n return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;\n}\n",
|
||||
"type": "registry:ui",
|
||||
"target": "components/atoms/Button.tsx"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "glide-menu",
|
||||
"type": "registry:ui",
|
||||
"title": "GlideMenu",
|
||||
"description": "Shared building block used by other Beautiful UI components.",
|
||||
"registryDependencies": [
|
||||
"https://www.beautifului.dev/r/foundation.json"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "components/primitives/GlideMenu.tsx",
|
||||
"content": "\"use client\";\n\nimport { useRef, useState, type ReactNode } from \"react\";\n\ntype GlideMenuProps = {\n children: ReactNode;\n className?: string;\n highlightClassName?: string;\n rowSelector?: string;\n};\n\n/** A single hover layer that glides between interactive menu rows. */\nexport default function GlideMenu({\n children,\n className = \"\",\n highlightClassName = \"inset-x-0 rounded-[8px] bg-hover\",\n rowSelector = \"[data-menu-row]\",\n}: GlideMenuProps) {\n const ref = useRef<HTMLDivElement>(null);\n const [box, setBox] = useState<{ top: number; height: number } | null>(null);\n const [visible, setVisible] = useState(false);\n\n const moveTo = (target: EventTarget | null) => {\n const container = ref.current;\n if (!(target instanceof Element) || !container) return;\n const row = target.closest(rowSelector);\n if (!(row instanceof HTMLElement) || !container.contains(row)) return;\n const containerRect = container.getBoundingClientRect();\n const rowRect = row.getBoundingClientRect();\n setBox({ top: rowRect.top - containerRect.top, height: rowRect.height });\n setVisible(true);\n };\n\n return (\n <div\n ref={ref}\n onMouseOver={(event) => moveTo(event.target)}\n onMouseLeave={() => setVisible(false)}\n onFocusCapture={(event) => moveTo(event.target)}\n onBlurCapture={(event) => {\n if (!ref.current?.contains(event.relatedTarget as Node | null)) setVisible(false);\n }}\n className={`group/glide-menu relative ${className}`}\n >\n <span\n aria-hidden\n className={`pointer-events-none absolute ${highlightClassName}`}\n style={{\n top: box?.top ?? 0,\n height: box?.height ?? 0,\n opacity: box && visible ? 1 : 0,\n transition:\n \"top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease\",\n }}\n />\n {children}\n </div>\n );\n}\n",
|
||||
"type": "registry:ui",
|
||||
"target": "components/primitives/GlideMenu.tsx"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,142 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||
"name": "beautifui",
|
||||
"homepage": "https://www.beautifului.dev",
|
||||
"items": [
|
||||
{
|
||||
"name": "foundation",
|
||||
"type": "registry:style",
|
||||
"title": "Beautiful UI foundation"
|
||||
},
|
||||
{
|
||||
"name": "button",
|
||||
"type": "registry:ui",
|
||||
"title": "Button"
|
||||
},
|
||||
{
|
||||
"name": "glide-menu",
|
||||
"type": "registry:ui",
|
||||
"title": "GlideMenu"
|
||||
},
|
||||
{
|
||||
"name": "entity-chip",
|
||||
"type": "registry:ui",
|
||||
"title": "EntityChip"
|
||||
},
|
||||
{
|
||||
"name": "value-pill",
|
||||
"type": "registry:ui",
|
||||
"title": "ValuePill"
|
||||
},
|
||||
{
|
||||
"name": "shimmer",
|
||||
"type": "registry:ui",
|
||||
"title": "Shimmer"
|
||||
},
|
||||
{
|
||||
"name": "stream-text",
|
||||
"type": "registry:ui",
|
||||
"title": "StreamText"
|
||||
},
|
||||
{
|
||||
"name": "loading-state",
|
||||
"type": "registry:component",
|
||||
"title": "Loading State"
|
||||
},
|
||||
{
|
||||
"name": "thinking-state",
|
||||
"type": "registry:component",
|
||||
"title": "Thinking"
|
||||
},
|
||||
{
|
||||
"name": "streaming-text",
|
||||
"type": "registry:component",
|
||||
"title": "Streaming Text"
|
||||
},
|
||||
{
|
||||
"name": "approval-card",
|
||||
"type": "registry:component",
|
||||
"title": "Approval Card"
|
||||
},
|
||||
{
|
||||
"name": "tool-chips",
|
||||
"type": "registry:component",
|
||||
"title": "Tool Chips"
|
||||
},
|
||||
{
|
||||
"name": "task-rows",
|
||||
"type": "registry:component",
|
||||
"title": "Task Rows"
|
||||
},
|
||||
{
|
||||
"name": "chat-composer",
|
||||
"type": "registry:component",
|
||||
"title": "Chat"
|
||||
},
|
||||
{
|
||||
"name": "prompt-bar",
|
||||
"type": "registry:component",
|
||||
"title": "Prompt Bar"
|
||||
},
|
||||
{
|
||||
"name": "recommendation-card",
|
||||
"type": "registry:component",
|
||||
"title": "Recommendation Card"
|
||||
},
|
||||
{
|
||||
"name": "context-cards",
|
||||
"type": "registry:component",
|
||||
"title": "Context Cards"
|
||||
},
|
||||
{
|
||||
"name": "diff-table",
|
||||
"type": "registry:component",
|
||||
"title": "Diff Table"
|
||||
},
|
||||
{
|
||||
"name": "records-table",
|
||||
"type": "registry:component",
|
||||
"title": "Records Table"
|
||||
},
|
||||
{
|
||||
"name": "filter-table",
|
||||
"type": "registry:component",
|
||||
"title": "Filter Table"
|
||||
},
|
||||
{
|
||||
"name": "sidebar-nav",
|
||||
"type": "registry:component",
|
||||
"title": "Sidebar Nav"
|
||||
},
|
||||
{
|
||||
"name": "search",
|
||||
"type": "registry:component",
|
||||
"title": "Search"
|
||||
},
|
||||
{
|
||||
"name": "flowchart",
|
||||
"type": "registry:component",
|
||||
"title": "Flowchart"
|
||||
},
|
||||
{
|
||||
"name": "insight-cards",
|
||||
"type": "registry:component",
|
||||
"title": "Insight Cards"
|
||||
},
|
||||
{
|
||||
"name": "code-block",
|
||||
"type": "registry:component",
|
||||
"title": "Code Block"
|
||||
},
|
||||
{
|
||||
"name": "fine-tune-card",
|
||||
"type": "registry:component",
|
||||
"title": "Fine-tune Card"
|
||||
},
|
||||
{
|
||||
"name": "selection-actions",
|
||||
"type": "registry:component",
|
||||
"title": "Selection Actions"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "shimmer",
|
||||
"type": "registry:ui",
|
||||
"title": "Shimmer",
|
||||
"description": "Shared building block used by other Beautiful UI components.",
|
||||
"registryDependencies": [
|
||||
"https://www.beautifului.dev/r/foundation.json"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "components/atoms/Shimmer.tsx",
|
||||
"content": "/** Shimmering label — signals the agent is processing. */\nexport function Shimmer({\n children,\n className = \"\",\n}: {\n children: React.ReactNode;\n className?: string;\n}) {\n return (\n <span\n className={`inline-block bg-clip-text text-transparent ${className}`}\n style={{\n backgroundImage:\n \"linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)\",\n backgroundSize: \"200% 100%\",\n animation: \"shimmer-text 1.8s linear infinite\",\n }}\n >\n {children}\n </span>\n );\n}\n",
|
||||
"type": "registry:ui",
|
||||
"target": "components/atoms/Shimmer.tsx"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,419 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { Button } from "@/components/atoms/Button";
|
||||
import GlideMenu from "@/components/primitives/GlideMenu";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* APPROVAL CARD (human-in-the-loop)
|
||||
* One question at a time. The stack slides vertically as you
|
||||
* move between questions (the card's height animates to fit),
|
||||
* the step counter rolls like an odometer, and the footer uses
|
||||
* pill actions — a quiet Skip and a dark Continue with a ⏎.
|
||||
* Single-choice answers auto-advance; multi-select waits.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
export type ApprovalQuestion = {
|
||||
q: string;
|
||||
type: "radio" | "check";
|
||||
options: string[];
|
||||
};
|
||||
|
||||
const QUESTIONS: ApprovalQuestion[] = [
|
||||
{
|
||||
q: "How many flavors should we launch?",
|
||||
type: "radio",
|
||||
options: ["Three (core line)", "Five (full case)", "Just one hero"],
|
||||
},
|
||||
{
|
||||
q: "Which mix-ins should we stock?",
|
||||
type: "check",
|
||||
options: ["Chocolate chips", "Waffle bits", "Sprinkles"],
|
||||
},
|
||||
{
|
||||
q: "Which market do we enter first?",
|
||||
type: "radio",
|
||||
options: ["Food trucks", "Grocery freezers", "Scoop shops"],
|
||||
},
|
||||
];
|
||||
|
||||
export type ApprovalLabels = {
|
||||
skip: string;
|
||||
continue: string;
|
||||
send: string;
|
||||
customPlaceholder: string;
|
||||
sentMessage: string;
|
||||
};
|
||||
|
||||
const DEFAULT_LABELS: ApprovalLabels = {
|
||||
skip: "Skip",
|
||||
continue: "Continue",
|
||||
send: "Send",
|
||||
customPlaceholder: "Something else…",
|
||||
sentMessage: "Answers sent",
|
||||
};
|
||||
|
||||
const ROLL_MS = 400;
|
||||
const SLIDE = "360ms cubic-bezier(0.22, 1, 0.36, 1)";
|
||||
|
||||
/* odometer digits — each character that changes rolls up (or down) */
|
||||
function RollingDigits({ value }: { value: string }) {
|
||||
const prevRef = useRef(value);
|
||||
const [oldVal, setOldVal] = useState(value);
|
||||
const [newVal, setNewVal] = useState(value);
|
||||
const [rolling, setRolling] = useState(false);
|
||||
const [shifted, setShifted] = useState(false);
|
||||
const [dir, setDir] = useState<"up" | "down">("up");
|
||||
|
||||
useEffect(() => {
|
||||
if (prevRef.current === value) return;
|
||||
const from = prevRef.current;
|
||||
prevRef.current = value;
|
||||
const fromN = parseInt(from, 10);
|
||||
const toN = parseInt(value, 10);
|
||||
setDir(Number.isFinite(fromN) && Number.isFinite(toN) && toN < fromN ? "down" : "up");
|
||||
setOldVal(from);
|
||||
setNewVal(value);
|
||||
setRolling(true);
|
||||
setShifted(false);
|
||||
|
||||
let raf2 = 0;
|
||||
const raf1 = requestAnimationFrame(() => {
|
||||
raf2 = requestAnimationFrame(() => setShifted(true));
|
||||
});
|
||||
const done = setTimeout(() => {
|
||||
setRolling(false);
|
||||
setOldVal(value);
|
||||
setShifted(false);
|
||||
}, ROLL_MS);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf1);
|
||||
cancelAnimationFrame(raf2);
|
||||
clearTimeout(done);
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
const chars = rolling ? newVal : oldVal;
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: chars.length }, (_, i) => {
|
||||
const o = oldVal[i] ?? "";
|
||||
const n = chars[i] ?? "";
|
||||
if (!rolling || o === n) {
|
||||
return <span key={`${i}-${n}`}>{n}</span>;
|
||||
}
|
||||
const top = dir === "down" ? n : o;
|
||||
const bottom = dir === "down" ? o : n;
|
||||
const restY = dir === "down" ? "0" : "-1em";
|
||||
const startY = dir === "down" ? "-1em" : "0";
|
||||
return (
|
||||
<span
|
||||
key={`${i}-${o}-${n}-${dir}`}
|
||||
style={{ display: "inline-block", position: "relative", overflow: "hidden", height: "1em", lineHeight: "1em", verticalAlign: "-0.05em" }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
transition: "transform 350ms cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
transform: `translateY(${shifted ? restY : startY})`,
|
||||
}}
|
||||
>
|
||||
<span style={{ height: "1em", lineHeight: "1em" }}>{top}</span>
|
||||
<span style={{ height: "1em", lineHeight: "1em" }}>{bottom}</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Ico({ path, size = 14, sw = 2 }: { path: React.ReactNode; size?: number; sw?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
{path}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ApprovalCard({
|
||||
questions = QUESTIONS,
|
||||
labels,
|
||||
onSubmitted,
|
||||
onAnswerChange,
|
||||
resettable = true,
|
||||
}: {
|
||||
questions?: ApprovalQuestion[];
|
||||
labels?: Partial<ApprovalLabels>;
|
||||
onSubmitted?: (answers: Record<number, number[]>) => void;
|
||||
onAnswerChange?: (questionIndex: number, answer: number[]) => void;
|
||||
resettable?: boolean;
|
||||
variant?: string;
|
||||
} = {}) {
|
||||
const t = { ...DEFAULT_LABELS, ...labels };
|
||||
const [qi, setQi] = useState(0);
|
||||
const [answers, setAnswers] = useState<Record<number, number[]>>({});
|
||||
const [custom, setCustom] = useState<Record<number, string>>({});
|
||||
const [sent, setSent] = useState(false);
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
const advanceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const questionRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const measured = useRef(false);
|
||||
const [viewportH, setViewportH] = useState<number | undefined>(undefined);
|
||||
const [trackY, setTrackY] = useState(0);
|
||||
const [animate, setAnimate] = useState(false);
|
||||
// Until the first question is measured, render only the active one so the
|
||||
// initial (and SSR) height is Q1's height — not all questions stacked, which
|
||||
// would flash to full height and then shrink on mount.
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const last = qi === questions.length - 1;
|
||||
const selected = answers[qi] ?? [];
|
||||
const hasAnswer = selected.length > 0 || Boolean(custom[qi]?.trim());
|
||||
|
||||
const sync = (withAnim: boolean) => {
|
||||
const item = questionRefs.current[qi];
|
||||
if (!item) return;
|
||||
const reduce = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
setViewportH(item.offsetHeight);
|
||||
setTrackY(item.offsetTop);
|
||||
setAnimate(withAnim && !reduce);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const withAnim = measured.current;
|
||||
measured.current = true;
|
||||
sync(withAnim);
|
||||
setReady(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [qi, answers, custom, open, sent]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => sync(measured.current));
|
||||
return () => cancelAnimationFrame(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [qi]);
|
||||
|
||||
useEffect(() => () => { if (advanceTimer.current) clearTimeout(advanceTimer.current); }, []);
|
||||
|
||||
const goTo = (next: number) => {
|
||||
if (advanceTimer.current) clearTimeout(advanceTimer.current);
|
||||
setQi(Math.min(Math.max(next, 0), questions.length - 1));
|
||||
};
|
||||
|
||||
const send = () => {
|
||||
if (advanceTimer.current) clearTimeout(advanceTimer.current);
|
||||
setSent(true);
|
||||
onSubmitted?.(answers);
|
||||
};
|
||||
|
||||
const advance = () => {
|
||||
if (last) send();
|
||||
else goTo(qi + 1);
|
||||
};
|
||||
|
||||
const toggle = (index: number) => {
|
||||
const type = questions[qi].type;
|
||||
setAnswers((current) => {
|
||||
const picked = current[qi] ?? [];
|
||||
const next = type === "radio"
|
||||
? [index]
|
||||
: picked.includes(index)
|
||||
? picked.filter((item) => item !== index)
|
||||
: [...picked, index];
|
||||
onAnswerChange?.(qi, next);
|
||||
return { ...current, [qi]: next };
|
||||
});
|
||||
if (type === "radio") {
|
||||
setCustom((current) => ({ ...current, [qi]: "" }));
|
||||
if (advanceTimer.current) clearTimeout(advanceTimer.current);
|
||||
advanceTimer.current = setTimeout(() => {
|
||||
if (last) send();
|
||||
else setQi((current) => Math.min(questions.length - 1, current + 1));
|
||||
}, 480);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setQi(0);
|
||||
setAnswers({});
|
||||
setCustom({});
|
||||
setSent(false);
|
||||
setOpen(true);
|
||||
measured.current = false;
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button type="button" onClick={() => setOpen(true)} className="rounded-control bg-surface px-3 py-2 text-[12.5px] font-medium text-ink shadow-btn transition-colors duration-150 hover:bg-hover">
|
||||
Open approval
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<div className="flex w-full max-w-80 items-center gap-3" style={{ animation: "pop-in 260ms cubic-bezier(0.23,1,0.32,1) both" }}>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-tint py-1 pr-2.5 pl-1 text-[12.5px] font-medium text-green">
|
||||
<span className="flex size-4.5 items-center justify-center rounded-full bg-green text-white">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
|
||||
</span>
|
||||
{t.sentMessage}
|
||||
</span>
|
||||
{resettable && (
|
||||
<button type="button" onClick={reset} className="text-[12px] font-medium text-ink-3 transition-colors duration-150 hover:text-ink">
|
||||
Start over
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-80">
|
||||
<div className="relative overflow-hidden rounded-card bg-surface shadow-card" style={{ animation: "fade-up 380ms cubic-bezier(0.23,1,0.32,1) both" }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss"
|
||||
onClick={() => setOpen(false)}
|
||||
className="primitive-icon-button absolute right-2.5 top-2.5 z-10 text-ink-3 transition-colors duration-100 hover:bg-hover hover:text-ink"
|
||||
>
|
||||
<Ico size={14} sw={2.2} path={<path d="M18 6L6 18M6 6l12 12" />} />
|
||||
</button>
|
||||
<div className="primitive-card-pad">
|
||||
{/* the question itself is the heading */}
|
||||
<div
|
||||
className="overflow-hidden"
|
||||
style={{ height: viewportH, transition: animate ? `height ${SLIDE}` : undefined }}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 26,
|
||||
transform: `translate3d(0, ${-trackY}px, 0)`,
|
||||
transition: animate ? `transform ${SLIDE}` : undefined,
|
||||
willChange: "transform",
|
||||
}}
|
||||
>
|
||||
{questions.map((question, qIdx) => {
|
||||
const active = qIdx === qi;
|
||||
// Before the first measure, mount only the active question so the
|
||||
// card opens at its real height instead of flashing to full height.
|
||||
if (!ready && !active) return null;
|
||||
const picked = answers[qIdx] ?? [];
|
||||
const questionStyle: CSSProperties = {
|
||||
opacity: active ? 1 : 0,
|
||||
transition: animate ? `opacity ${SLIDE}` : undefined,
|
||||
pointerEvents: active ? undefined : "none",
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={qIdx}
|
||||
ref={(el) => { questionRefs.current[qIdx] = el; }}
|
||||
aria-hidden={active ? undefined : true}
|
||||
style={questionStyle}
|
||||
>
|
||||
<div className="pr-7 text-[14px] font-medium text-ink">{question.q}</div>
|
||||
<GlideMenu className="mt-2.5 flex flex-col gap-1" highlightClassName="inset-x-0 rounded-control bg-hover">
|
||||
{question.options.map((option, i) => {
|
||||
const on = picked.includes(i);
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
data-menu-row
|
||||
aria-pressed={on}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => { if (active) toggle(i); }}
|
||||
className="relative z-10 flex items-center gap-1.5 rounded-control pl-1 pr-2 py-1 text-left transition-colors duration-100"
|
||||
>
|
||||
<span
|
||||
className={`flex size-4 shrink-0 items-center justify-center transition-colors duration-200
|
||||
${question.type === "radio" ? "rounded-full" : "rounded-[5px]"}
|
||||
${on ? "bg-ink text-canvas" : "shadow-[inset_0_0_0_1.5px_var(--line-strong)] text-transparent"}`}
|
||||
>
|
||||
{question.type === "radio" ? (
|
||||
<span className="size-1.5 rounded-full bg-canvas transition-transform duration-200" style={{ transform: on ? "scale(1)" : "scale(0)" }} />
|
||||
) : (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-[13px] leading-none transition-colors duration-200 ${on ? "text-ink" : "text-ink-2"}`}>
|
||||
{option}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<label data-menu-row className="relative z-10 flex items-center gap-1.5 rounded-control pl-1 pr-2 py-1 transition-colors duration-100">
|
||||
<input
|
||||
value={custom[qIdx] ?? ""}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onChange={(event) => {
|
||||
if (!active) return;
|
||||
setCustom((current) => ({ ...current, [qIdx]: event.target.value }));
|
||||
if (question.type === "radio") setAnswers((current) => ({ ...current, [qIdx]: [] }));
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && hasAnswer) {
|
||||
event.preventDefault();
|
||||
advance();
|
||||
}
|
||||
}}
|
||||
placeholder={t.customPlaceholder}
|
||||
aria-label="Custom answer"
|
||||
className="min-w-0 flex-1 bg-transparent pl-1.5 text-[13px] text-ink outline-none placeholder:text-ink-3"
|
||||
/>
|
||||
</label>
|
||||
</GlideMenu>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* footer — step nav (rolling counter) + pill actions */}
|
||||
<div className="primitive-card-footer flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1 text-ink-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous question"
|
||||
disabled={qi <= 0}
|
||||
onClick={() => goTo(qi - 1)}
|
||||
className="flex size-[18px] items-center justify-center rounded-[5px] transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30"
|
||||
>
|
||||
<Ico size={14} path={<path d="M18 15l-6-6-6 6" />} />
|
||||
</button>
|
||||
<span className="inline-flex items-center text-[12px] font-medium tabular-nums text-ink-3" style={{ letterSpacing: "-0.1px", lineHeight: 1 }}>
|
||||
<RollingDigits value={`${qi + 1} / ${questions.length}`} />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next question"
|
||||
disabled={last}
|
||||
onClick={() => goTo(qi + 1)}
|
||||
className="flex size-[18px] items-center justify-center rounded-[5px] transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30"
|
||||
>
|
||||
<Ico size={14} path={<path d="M6 9l6 6 6-6" />} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="-mr-0.5 flex items-center gap-1.5">
|
||||
<Button variant="ghost" size="sm" onClick={() => (last ? setOpen(false) : goTo(qi + 1))}>
|
||||
{t.skip}
|
||||
</Button>
|
||||
<Button variant="accent" size="sm" disabled={!hasAnswer} onClick={advance}>
|
||||
{last ? t.send : t.continue}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ButtonHTMLAttributes } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const filledShadow = "shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]";
|
||||
|
||||
/* Pill-shaped by default — the app's core button style. Explicit symmetric
|
||||
* padding (not a fixed height) so the top/bottom spacing is always equal. */
|
||||
export const buttonVariants = cva(
|
||||
`inline-flex items-center justify-center font-medium select-none
|
||||
transition-[transform,background-color,opacity] duration-150 ease-out
|
||||
active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none`,
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: `bg-ink text-canvas hover:opacity-90 dark:bg-ink dark:text-canvas ${filledShadow}`,
|
||||
secondary: "bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover",
|
||||
ghost: "bg-hover-2 text-ink hover:bg-line-strong",
|
||||
accent: `bg-accent text-white hover:bg-accent-ink ${filledShadow}`,
|
||||
success: `bg-green text-white hover:brightness-95 ${filledShadow}`,
|
||||
/* transparent until hovered — for dense toolbars/action rows */
|
||||
quiet: "text-ink hover:bg-hover",
|
||||
},
|
||||
size: {
|
||||
/* compact toolbar pill — fixed height, lighter weight */
|
||||
xs: "h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1",
|
||||
/* canonical action pill — 27px tall, roomy sides */
|
||||
sm: "h-[27px] px-3 text-[13px] leading-none rounded-full gap-1.5",
|
||||
md: "px-4 py-[9px] text-sm leading-none rounded-full gap-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "secondary", size: "md" },
|
||||
},
|
||||
);
|
||||
|
||||
export type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>["variant"]>;
|
||||
|
||||
export function Button({
|
||||
variant,
|
||||
size,
|
||||
className,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>) {
|
||||
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
|
||||
type GlideMenuProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
highlightClassName?: string;
|
||||
rowSelector?: string;
|
||||
};
|
||||
|
||||
/** A single hover layer that glides between interactive menu rows. */
|
||||
export default function GlideMenu({
|
||||
children,
|
||||
className = "",
|
||||
highlightClassName = "inset-x-0 rounded-[8px] bg-hover",
|
||||
rowSelector = "[data-menu-row]",
|
||||
}: GlideMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [box, setBox] = useState<{ top: number; height: number } | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const moveTo = (target: EventTarget | null) => {
|
||||
const container = ref.current;
|
||||
if (!(target instanceof Element) || !container) return;
|
||||
const row = target.closest(rowSelector);
|
||||
if (!(row instanceof HTMLElement) || !container.contains(row)) return;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
setBox({ top: rowRect.top - containerRect.top, height: rowRect.height });
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onMouseOver={(event) => moveTo(event.target)}
|
||||
onMouseLeave={() => setVisible(false)}
|
||||
onFocusCapture={(event) => moveTo(event.target)}
|
||||
onBlurCapture={(event) => {
|
||||
if (!ref.current?.contains(event.relatedTarget as Node | null)) setVisible(false);
|
||||
}}
|
||||
className={`group/glide-menu relative ${className}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`pointer-events-none absolute ${highlightClassName}`}
|
||||
style={{
|
||||
top: box?.top ?? 0,
|
||||
height: box?.height ?? 0,
|
||||
opacity: box && visible ? 1 : 0,
|
||||
transition:
|
||||
"top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease",
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* LOADING STATE — pixel-grid loader for long-running work
|
||||
*
|
||||
* Variants:
|
||||
* Drive — square cells, chevron wavefront driving right;
|
||||
* the 650ms cycle is shorter than the sweep, so
|
||||
* two fronts are always in flight
|
||||
* Dots — same wavefront, circular cells
|
||||
* Orbit — a comet lapping the grid perimeter
|
||||
* Surfer — the Drive loader paired with a meme video below
|
||||
*
|
||||
* Paired with a shimmering label and a live elapsed timer
|
||||
* in mono tabular figures. Reduced motion freezes the grid
|
||||
* to its dim state; the timer still ticks.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
const chevron = Array.from({ length: 9 }, (_, i) => {
|
||||
const r = Math.floor(i / 3), c = i % 3;
|
||||
return (c + Math.abs(r - 1)) * 90;
|
||||
});
|
||||
|
||||
const ORBIT_ORDER = [0, 1, 2, 5, 8, 7, 6, 3];
|
||||
const orbit = Array.from({ length: 9 }, (_, i) => {
|
||||
const k = ORBIT_ORDER.indexOf(i);
|
||||
return k === -1 ? null : k * 110;
|
||||
});
|
||||
|
||||
const PATTERNS: Record<string, { delays: (number | null)[]; dur: number; round: boolean }> = {
|
||||
Drive: { delays: chevron, dur: 650, round: false },
|
||||
Dots: { delays: chevron, dur: 650, round: true },
|
||||
Orbit: { delays: orbit, dur: 950, round: false },
|
||||
};
|
||||
|
||||
function LoaderGrid({
|
||||
delays,
|
||||
dur,
|
||||
round,
|
||||
}: {
|
||||
delays: (number | null)[];
|
||||
dur: number;
|
||||
round: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span aria-hidden className="grid shrink-0 grid-cols-[repeat(3,4px)] gap-[1.5px]">
|
||||
{delays.map((delay, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`size-[4px] bg-ink ${round ? "rounded-full" : "rounded-[1px]"}`}
|
||||
style={{
|
||||
opacity: delay === null ? 0.07 : 0.15,
|
||||
animation: delay === null ? "none" : `pixel-on ${dur}ms ease-in-out ${delay}ms infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function useElapsed() {
|
||||
const [ds, setDs] = useState(0);
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setDs((d) => d + 1), 100);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
const total = ds / 10;
|
||||
if (total < 60) return `${total.toFixed(1)}s`;
|
||||
return `${Math.floor(total / 60)}m ${(total % 60).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export default function LoadingState({
|
||||
label,
|
||||
variant = "Drive",
|
||||
/** the meme feed for the Surfer variant; hosted on Vercel Blob so it plays in
|
||||
* production (the local /public/subway-surfers.mp4 stays gitignored) */
|
||||
videoSrc = "https://95dnc2a95qgwt9ff.public.blob.vercel-storage.com/subway-surfers.mp4",
|
||||
}: {
|
||||
label?: string;
|
||||
variant?: string;
|
||||
videoSrc?: string;
|
||||
}) {
|
||||
const elapsed = useElapsed();
|
||||
const surfer = variant === "Surfer";
|
||||
const resolvedLabel = label ?? (surfer ? "Subway surfing" : "Churning");
|
||||
const [videoOk, setVideoOk] = useState(true);
|
||||
const { delays, dur, round } = PATTERNS[variant] ?? PATTERNS.Drive;
|
||||
|
||||
const labelEl = (
|
||||
<span
|
||||
className="bg-clip-text text-[13px] font-medium text-transparent"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)",
|
||||
backgroundSize: "200% 100%",
|
||||
animation: "shimmer-text 1.4s linear infinite",
|
||||
}}
|
||||
>
|
||||
{resolvedLabel}
|
||||
</span>
|
||||
);
|
||||
const elapsedEl = <span className="font-mono text-[12px] text-ink-3 tabular-nums">{elapsed}</span>;
|
||||
|
||||
if (surfer) {
|
||||
return (
|
||||
<div role="status" className="flex w-fit flex-col items-start">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<LoaderGrid {...PATTERNS.Drive} />
|
||||
{labelEl}
|
||||
{elapsedEl}
|
||||
</div>
|
||||
|
||||
{/* the context card follows the status text it is illustrating */}
|
||||
<div
|
||||
className="mt-2 w-56 overflow-hidden rounded-[10px] shadow-overlay"
|
||||
style={{ animation: "pop-in 200ms cubic-bezier(0.16,1,0.3,1) both", transformOrigin: "top left" }}
|
||||
>
|
||||
<div className="relative aspect-video w-full" style={{ background: "var(--tooltip-bg)" }}>
|
||||
{videoOk ? (
|
||||
<video
|
||||
src={videoSrc}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
onError={() => setVideoOk(false)}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1.5">
|
||||
<LoaderGrid {...PATTERNS.Drive} />
|
||||
<span className="px-3 text-center font-mono text-[10px]" style={{ color: "var(--tooltip-muted)" }}>
|
||||
Video unavailable
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="status" className="flex w-fit items-center gap-2.5">
|
||||
<LoaderGrid delays={delays} dur={dur} round={round} />
|
||||
{labelEl}
|
||||
{elapsedEl}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,449 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { IconArrowBoxLeft } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconArrowBoxLeft";
|
||||
import { IconCheckmark1Small } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconCheckmark1Small";
|
||||
import { IconChevronDownSmall } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconChevronDownSmall";
|
||||
import { IconCrossSmall } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconCrossSmall";
|
||||
import { IconEditBig } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconEditBig";
|
||||
import { IconHome } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconHome";
|
||||
import { IconMagnifyingGlass } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconMagnifyingGlass";
|
||||
import { IconPlusMedium } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconPlusMedium";
|
||||
import { IconPopsicle2 } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconPopsicle2";
|
||||
import { IconSettingsGear1 } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconSettingsGear1";
|
||||
import { IconSidebarLeftArrow } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconSidebarLeftArrow";
|
||||
import { IconUserAdd } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconUserAdd";
|
||||
import GlideMenu from "@/components/primitives/GlideMenu";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* SIDEBAR NAV
|
||||
* Shared by the design-system preview and the harness shell:
|
||||
* compact workspace switcher, primary navigation, searchable
|
||||
* chat history, and a collapse that preserves icon alignment.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
const WORKSPACE = { key: "creamery", name: "Creamery Ops", monogram: "C" };
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ key: "home", label: "Home", icon: <IconHome size={18} /> },
|
||||
{ key: "invite", label: "Invite users", icon: <IconUserAdd size={18} />, count: "3/10" },
|
||||
];
|
||||
|
||||
export type SidebarRecent = {
|
||||
id: string;
|
||||
label: string;
|
||||
prompt?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_RECENTS: SidebarRecent[] = [
|
||||
{ id: "suppliers", label: "Supplier records" },
|
||||
{ id: "todos", label: "Urgent to-dos this morning" },
|
||||
{ id: "flavor", label: "Flavor page ticket" },
|
||||
{ id: "workload", label: "Workload summary" },
|
||||
{ id: "offboarding", label: "Off-board a supplier" },
|
||||
{ id: "restock", label: "Batch restock function" },
|
||||
{ id: "edits", label: "Propose flavor edits" },
|
||||
{ id: "subway", label: "Subway surfing" },
|
||||
];
|
||||
|
||||
type SidebarNavProps = {
|
||||
activeTitle?: string | null;
|
||||
className?: string;
|
||||
fill?: boolean;
|
||||
onNewChat?: () => void;
|
||||
onPick?: (id: string, label: string, prompt?: string) => void;
|
||||
/** controlled primary-nav selection (e.g. "home" | "invite") */
|
||||
activeNav?: string;
|
||||
onNavigate?: (key: string) => void;
|
||||
/** footer call-to-action — defaults to the demo "Upgrade" button */
|
||||
footerLabel?: string;
|
||||
footerIcon?: ReactNode;
|
||||
onFooterClick?: () => void;
|
||||
recents?: SidebarRecent[];
|
||||
variant?: string;
|
||||
};
|
||||
|
||||
const SIDEBAR_MOTION = {
|
||||
expandedWidth: 224,
|
||||
collapsedWidth: 52,
|
||||
duration: 280,
|
||||
copyDuration: 180,
|
||||
copyOffset: 8,
|
||||
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
};
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* CHAT SEARCH STORYBOARD
|
||||
*
|
||||
* 0ms search is triggered; Chats label begins fading
|
||||
* 0ms field grows right → left from the search control
|
||||
* 180ms field fills the row; cursor is focused and ready
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
const CHAT_SEARCH_MOTION = {
|
||||
duration: 180,
|
||||
closedWidth: 28,
|
||||
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
};
|
||||
|
||||
function GlideGroup({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<GlideMenu
|
||||
rowSelector="[data-row]"
|
||||
highlightClassName="sidebar-glide-highlight rounded-[7px] bg-hover-2"
|
||||
className="group/glide flex flex-col gap-px"
|
||||
>
|
||||
{children}
|
||||
</GlideMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function RailButton({
|
||||
icon,
|
||||
label,
|
||||
active = false,
|
||||
count,
|
||||
onClick,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
count?: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
data-row
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`sidebar-row relative z-10 mx-2 flex h-8 items-center rounded-[8px] px-2 text-left
|
||||
transition-[width,background-color,color,transform] duration-150 active:scale-[0.98]
|
||||
${active ? "bg-hover-2 group-hover/glide:bg-transparent" : ""}`}
|
||||
>
|
||||
<span className={`flex size-5 shrink-0 items-center justify-center ${active ? "text-ink" : "text-ink-2"}`}>
|
||||
{icon}
|
||||
</span>
|
||||
<span className={`sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[14px] font-medium ${active ? "text-ink" : "text-ink-2"}`}>
|
||||
{label}
|
||||
</span>
|
||||
{count && (
|
||||
<span className="sidebar-copy mr-2 shrink-0 text-[12px] font-medium tabular-nums text-ink-3">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceMenu({
|
||||
position,
|
||||
onClose,
|
||||
}: {
|
||||
position: { top: number; left: number };
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return createPortal(
|
||||
<div
|
||||
data-workspace-menu
|
||||
className="fixed z-50 w-64 rounded-[14px] bg-surface p-1.5 shadow-overlay"
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
animation: "pop-in 180ms cubic-bezier(0.23,1,0.32,1) both",
|
||||
transformOrigin: "top left",
|
||||
}}
|
||||
>
|
||||
<GlideMenu className="flex flex-col gap-px" highlightClassName="inset-x-0 rounded-[8px] bg-hover-2">
|
||||
<button
|
||||
data-menu-row
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="relative z-10 flex h-10 w-full items-center gap-1.5 rounded-[8px] px-2 text-left"
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-[7px] bg-ink text-[11px] font-semibold text-surface">
|
||||
{WORKSPACE.monogram}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[13.5px] font-medium text-ink">{WORKSPACE.name}</span>
|
||||
<span className="shrink-0 text-ink"><IconCheckmark1Small size={18} /></span>
|
||||
</button>
|
||||
<div className="my-1 h-px bg-line" />
|
||||
{[
|
||||
{ label: "New workspace", icon: <IconPlusMedium size={16} /> },
|
||||
{ label: "Workspace settings", icon: <IconSettingsGear1 size={16} /> },
|
||||
{ label: "Invite team members", icon: <IconUserAdd size={16} /> },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
data-menu-row
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="relative z-10 flex h-9 w-full items-center gap-1.5 rounded-[8px] px-2 text-left"
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center text-ink-2">{item.icon}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[13.5px] text-ink">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="my-1 h-px bg-line" />
|
||||
<button
|
||||
data-menu-row
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="relative z-10 flex h-9 w-full items-center gap-1.5 rounded-[8px] px-2 text-left"
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center text-ink-2"><IconArrowBoxLeft size={16} /></span>
|
||||
<span className="min-w-0 flex-1 truncate text-[13.5px] text-ink">Sign out</span>
|
||||
</button>
|
||||
</GlideMenu>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export default function SidebarNav({
|
||||
activeTitle,
|
||||
className = "",
|
||||
fill = false,
|
||||
onNewChat,
|
||||
onPick,
|
||||
activeNav,
|
||||
onNavigate,
|
||||
footerLabel = "Upgrade",
|
||||
footerIcon,
|
||||
onFooterClick,
|
||||
recents = DEFAULT_RECENTS,
|
||||
}: SidebarNavProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [internalNav, setInternalNav] = useState("chats");
|
||||
const currentNav = activeNav ?? internalNav;
|
||||
const selectNav = (key: string) => {
|
||||
setInternalNav(key);
|
||||
onNavigate?.(key);
|
||||
};
|
||||
const [demoActiveTitle, setDemoActiveTitle] = useState<string | null>(null);
|
||||
const [workspaceOpen, setWorkspaceOpen] = useState(false);
|
||||
const [workspacePosition, setWorkspacePosition] = useState({ top: 0, left: 0 });
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const workspaceButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const selectedTitle = activeTitle === undefined ? demoActiveTitle : activeTitle;
|
||||
const visibleRecents = recents.filter((item) => item.label.toLowerCase().includes(query.trim().toLowerCase()));
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceOpen) return;
|
||||
const close = (event: PointerEvent) => {
|
||||
const target = event.target as Element;
|
||||
if (!target.closest("[data-workspace-trigger]") && !target.closest("[data-workspace-menu]")) {
|
||||
setWorkspaceOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", close);
|
||||
return () => document.removeEventListener("pointerdown", close);
|
||||
}, [workspaceOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchOpen) searchRef.current?.focus();
|
||||
}, [searchOpen]);
|
||||
|
||||
const collapse = () => {
|
||||
setCollapsed(true);
|
||||
setWorkspaceOpen(false);
|
||||
setSearchOpen(false);
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-sidebar-collapsed={collapsed}
|
||||
aria-label="Workspace navigation"
|
||||
className={`relative flex shrink-0 overflow-hidden transition-[width] ${fill ? "h-full" : "h-[600px]"} ${className}`}
|
||||
style={{
|
||||
width: collapsed ? SIDEBAR_MOTION.collapsedWidth : SIDEBAR_MOTION.expandedWidth,
|
||||
transitionDuration: `${SIDEBAR_MOTION.duration}ms`,
|
||||
transitionTimingFunction: SIDEBAR_MOTION.easing,
|
||||
"--sidebar-copy-duration": `${SIDEBAR_MOTION.copyDuration}ms`,
|
||||
"--sidebar-copy-offset": `${SIDEBAR_MOTION.copyOffset}px`,
|
||||
"--sidebar-easing": SIDEBAR_MOTION.easing,
|
||||
} as CSSProperties}
|
||||
>
|
||||
<div className="flex min-h-0 w-[224px] shrink-0 flex-col">
|
||||
<div className="relative mb-2.5 h-10 shrink-0">
|
||||
<button
|
||||
ref={workspaceButtonRef}
|
||||
data-workspace-trigger
|
||||
type="button"
|
||||
aria-expanded={workspaceOpen}
|
||||
aria-hidden={collapsed}
|
||||
tabIndex={collapsed ? -1 : 0}
|
||||
onClick={() => {
|
||||
if (!workspaceOpen && workspaceButtonRef.current) {
|
||||
const rect = workspaceButtonRef.current.getBoundingClientRect();
|
||||
setWorkspacePosition({ top: rect.bottom + 6, left: rect.left });
|
||||
}
|
||||
setWorkspaceOpen((open) => !open);
|
||||
}}
|
||||
className="sidebar-workspace-control absolute left-2 top-1 flex h-8 w-[164px] items-center rounded-[8px] px-2 text-left transition-[background-color,transform] duration-100 hover:bg-hover-2 active:scale-[0.99]"
|
||||
>
|
||||
<span className="sidebar-logo flex size-5 shrink-0 items-center justify-center text-ink">
|
||||
<IconPopsicle2 size={18} />
|
||||
</span>
|
||||
<span className="sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[14px] font-medium text-ink-2">
|
||||
{WORKSPACE.name}
|
||||
</span>
|
||||
<span className="sidebar-copy ml-1 flex shrink-0 text-ink-3">
|
||||
<IconChevronDownSmall size={16} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{workspaceOpen && <WorkspaceMenu position={workspacePosition} onClose={() => setWorkspaceOpen(false)} />}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Collapse sidebar"
|
||||
aria-hidden={collapsed}
|
||||
tabIndex={collapsed ? -1 : 0}
|
||||
onClick={collapse}
|
||||
className="sidebar-collapse-control absolute right-2 top-1 flex size-8 items-center justify-center rounded-[8px] text-ink-3 transition-[opacity,background-color,color] duration-150 hover:bg-hover-2 hover:text-ink"
|
||||
>
|
||||
<IconSidebarLeftArrow size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Expand sidebar"
|
||||
aria-hidden={!collapsed}
|
||||
tabIndex={collapsed ? 0 : -1}
|
||||
onClick={() => setCollapsed(false)}
|
||||
className="sidebar-expand-control absolute left-2 top-0.5 flex size-9 items-center justify-center rounded-[8px] text-ink-3 transition-[opacity,background-color,color] duration-150 hover:bg-hover-2 hover:text-ink"
|
||||
>
|
||||
<IconSidebarLeftArrow size={18} className="rotate-180" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<GlideGroup>
|
||||
<RailButton
|
||||
icon={<IconEditBig size={18} />}
|
||||
label="New chat"
|
||||
onClick={() => {
|
||||
if (activeTitle === undefined) setDemoActiveTitle(null);
|
||||
selectNav("chats");
|
||||
onNewChat?.();
|
||||
}}
|
||||
/>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<RailButton
|
||||
key={item.key}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
count={item.count}
|
||||
active={currentNav === item.key}
|
||||
onClick={() => selectNav(item.key)}
|
||||
/>
|
||||
))}
|
||||
</GlideGroup>
|
||||
|
||||
<div className="mt-3 min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="sidebar-copy relative mx-2 mb-1 h-8">
|
||||
<div
|
||||
aria-hidden={searchOpen}
|
||||
className={`absolute inset-0 flex items-center gap-1.5 px-2 text-[12.5px] font-medium text-ink-3 transition-[opacity,transform] ${searchOpen ? "pointer-events-none -translate-x-1 opacity-0" : "translate-x-0 opacity-100"}`}
|
||||
style={{ transitionDuration: `${CHAT_SEARCH_MOTION.duration}ms`, transitionTimingFunction: CHAT_SEARCH_MOTION.easing }}
|
||||
>
|
||||
<IconChevronDownSmall size={16} />
|
||||
<span>Chats</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Search chats"
|
||||
aria-expanded={searchOpen}
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className={`absolute right-0 top-0 z-10 flex size-8 items-center justify-center rounded-[8px] text-ink-3 transition-[opacity,background-color,color,transform] hover:bg-hover-2 hover:text-ink active:scale-[0.96] ${searchOpen ? "pointer-events-none opacity-0" : "opacity-100"}`}
|
||||
style={{ transitionDuration: `${CHAT_SEARCH_MOTION.duration}ms` }}
|
||||
>
|
||||
<IconMagnifyingGlass size={16} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`absolute right-0 top-0 z-20 flex h-8 items-center overflow-hidden rounded-[8px] bg-field text-ink-3 shadow-hairline transition-[width,opacity] focus-within:text-ink-2 ${searchOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`}
|
||||
style={{
|
||||
width: searchOpen ? "100%" : CHAT_SEARCH_MOTION.closedWidth,
|
||||
transitionDuration: `${CHAT_SEARCH_MOTION.duration}ms`,
|
||||
transitionTimingFunction: CHAT_SEARCH_MOTION.easing,
|
||||
}}
|
||||
>
|
||||
<span className="ml-2 flex shrink-0 items-center justify-center">
|
||||
<IconMagnifyingGlass size={15} />
|
||||
</span>
|
||||
<input
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
setSearchOpen(false);
|
||||
setQuery("");
|
||||
}
|
||||
}}
|
||||
placeholder="Search chats"
|
||||
aria-label="Search chat history"
|
||||
className="ml-1.5 min-w-0 flex-1 bg-transparent text-[13px] font-medium text-ink outline-none placeholder:text-ink-3"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close chat search"
|
||||
onClick={() => {
|
||||
setSearchOpen(false);
|
||||
setQuery("");
|
||||
}}
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-[8px] text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover-2 hover:text-ink active:scale-[0.96]"
|
||||
>
|
||||
<IconCrossSmall size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GlideGroup>
|
||||
{visibleRecents.map((item) => {
|
||||
const active = item.label === selectedTitle;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
data-row
|
||||
type="button"
|
||||
title={item.label}
|
||||
onClick={() => {
|
||||
selectNav("chats");
|
||||
if (activeTitle === undefined) setDemoActiveTitle(item.label);
|
||||
onPick?.(item.id, item.label, item.prompt);
|
||||
}}
|
||||
className={`sidebar-row relative z-10 mx-2 flex h-8 items-center rounded-[8px] px-2 text-left transition-[width,background-color,color,transform] duration-150 active:scale-[0.98] ${
|
||||
active ? "bg-hover-2 group-hover/glide:bg-transparent" : ""
|
||||
}`}
|
||||
>
|
||||
<span className={`sidebar-copy min-w-0 flex-1 truncate text-[14px] font-medium ${active ? "text-ink" : "text-ink-2"}`}>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{query && visibleRecents.length === 0 && (
|
||||
<div className="sidebar-copy mx-2 px-2 py-2 text-[12.5px] text-ink-3">No chats found</div>
|
||||
)}
|
||||
</GlideGroup>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-copy mx-2 mt-3 w-[208px] border-t border-line pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onFooterClick ?? onNewChat}
|
||||
className="flex h-8 w-full items-center justify-center gap-1.5 rounded-control bg-hover-2 text-[12.5px] font-medium text-ink transition-[background-color,transform] duration-150 hover:bg-line-strong active:scale-[0.98]"
|
||||
>
|
||||
{footerIcon}
|
||||
{footerLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* STREAMING TEXT
|
||||
* Words resolve out of blur, inline citations appear in
|
||||
* context, then actions and follow-up prompts become usable.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
const WORD_MS = 55;
|
||||
const HOLD_MS = 3400;
|
||||
|
||||
/* one streamed word, or a `cite` placeholder that renders an inline source chip */
|
||||
export type StreamingToken = { text: string; cite?: boolean };
|
||||
|
||||
const TOKENS: StreamingToken[] = [
|
||||
..."Pistachio is your fastest-growing flavor — sales are up 23% this month and margins beat vanilla by 8 points."
|
||||
.split(" ")
|
||||
.map((text) => ({ text })),
|
||||
{ text: "", cite: true },
|
||||
..."Stone-fruit flavors are trending in the same range."
|
||||
.split(" ")
|
||||
.map((text) => ({ text })),
|
||||
];
|
||||
|
||||
const FOLLOW_UPS = [
|
||||
"Which flavors sell best in winter",
|
||||
"Compare gelato and soft serve margins",
|
||||
];
|
||||
|
||||
const SOURCE_IMAGES = {
|
||||
scoop:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%231f7a5f'/%3E%3Cpath d='M20 36c0 7 5.4 12 12 12s12-5 12-12H20Z' fill='%23fff'/%3E%3Ccircle cx='32' cy='25' r='11' fill='%23bff3dd'/%3E%3Cpath d='M24 24c4-7 13-7 17 0' fill='none' stroke='%231f7a5f' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E",
|
||||
trends:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%232f6fec'/%3E%3Cpath d='M15 43 27 31l8 7 14-18' fill='none' stroke='%23fff' stroke-width='7' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='49' cy='20' r='5' fill='%23bfe0ff'/%3E%3C/svg%3E",
|
||||
market:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23e56d24'/%3E%3Cpath d='M17 45V25h8v20h-8Zm11 0V16h8v29h-8Zm11 0V30h8v15h-8Z' fill='%23fff'/%3E%3Cpath d='M16 49h32' stroke='%23ffd6b8' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E",
|
||||
};
|
||||
|
||||
/* one cited source rendered as an inline chip and in the sources list */
|
||||
export type StreamingSource = { name: string; domain: string; href: string; image: string };
|
||||
|
||||
const SOURCES: StreamingSource[] = [
|
||||
{ name: "Scoop Data", domain: "scoopdata.io", href: "https://scoopdata.io/", image: SOURCE_IMAGES.scoop },
|
||||
{ name: "Trends Index", domain: "trends.google.com", href: "https://trends.google.com/trends/", image: SOURCE_IMAGES.trends },
|
||||
{ name: "Market Basket", domain: "marketbasket.io", href: "https://marketbasket.io/", image: SOURCE_IMAGES.market },
|
||||
];
|
||||
|
||||
function sourceImage(source: StreamingSource) {
|
||||
return source.image;
|
||||
}
|
||||
|
||||
function SourceChip({ source }: { source?: StreamingSource }) {
|
||||
if (!source) return null;
|
||||
return (
|
||||
<a
|
||||
href={source.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-0 mr-1 inline-flex h-4.5 translate-y-[-1px] items-center gap-1 rounded-[5px]
|
||||
bg-inset pr-[3px] pl-[3px] align-middle font-mono text-[10.5px] text-ink-2 shadow-hairline
|
||||
transition-colors duration-150 hover:bg-hover hover:text-ink"
|
||||
style={{ animation: "pop-in 250ms cubic-bezier(0.23,1,0.32,1) both" }}
|
||||
>
|
||||
<img src={sourceImage(source)} alt="" className="source-avatar size-3 rounded-[3px]" />
|
||||
<span>{source.domain}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const ACTION_ICONS: React.ReactNode[] = [
|
||||
<g key="copy"><rect x="9" y="9" width="12" height="12" rx="2.5" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></g>,
|
||||
<path key="retry" d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6" />,
|
||||
<path key="up" d="M7 10v12M15 5.88L14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88z" />,
|
||||
<path key="down" d="M17 14V2M9 18.12L10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88z" />,
|
||||
];
|
||||
|
||||
export type StreamingLabels = {
|
||||
/** label on the collapsed sources toggle */
|
||||
sources: string;
|
||||
/** heading above the follow-up prompts */
|
||||
followUps: string;
|
||||
};
|
||||
|
||||
const DEFAULT_LABELS: StreamingLabels = {
|
||||
sources: "10 sources",
|
||||
followUps: "Follow-ups",
|
||||
};
|
||||
|
||||
export default function StreamingText({
|
||||
content = TOKENS,
|
||||
sources = SOURCES,
|
||||
followUps = FOLLOW_UPS,
|
||||
labels,
|
||||
loop = true,
|
||||
fill = false,
|
||||
onDone,
|
||||
onFollowUp,
|
||||
}: {
|
||||
variant?: string;
|
||||
/** the streamed tokens; `cite` tokens render an inline source chip */
|
||||
content?: StreamingToken[];
|
||||
/** cited sources shown in the chip, avatar stack, and expanded list */
|
||||
sources?: StreamingSource[];
|
||||
/** follow-up prompt suggestions shown once the stream completes */
|
||||
followUps?: string[];
|
||||
/** prominent copy strings */
|
||||
labels?: Partial<StreamingLabels>;
|
||||
/** restart the stream after a hold; turn off when embedding in a real thread */
|
||||
loop?: boolean;
|
||||
/** fill the parent width instead of the gallery's fixed measure */
|
||||
fill?: boolean;
|
||||
onDone?: () => void;
|
||||
/** fired when a follow-up prompt is chosen */
|
||||
onFollowUp?: (text: string, index: number) => void;
|
||||
} = {}) {
|
||||
const l = { ...DEFAULT_LABELS, ...labels };
|
||||
const [count, setCount] = useState(0);
|
||||
const [sourcesOpen, setSourcesOpen] = useState(false);
|
||||
const done = count >= content.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (done && !loop) {
|
||||
onDone?.();
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(
|
||||
() => setCount((c) => (c >= content.length ? 0 : c + 1)),
|
||||
done ? HOLD_MS : WORD_MS,
|
||||
);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [count, done, loop]);
|
||||
|
||||
return (
|
||||
<div className={fill ? "w-full" : "min-h-[15.5rem] w-full max-w-95"}>
|
||||
<p className="text-[13px] leading-relaxed text-ink">
|
||||
{content.slice(0, count).map((token, i) =>
|
||||
token.cite ? (
|
||||
<SourceChip key={i} source={sources[0]} />
|
||||
) : (
|
||||
<span key={i} className="inline">
|
||||
{token.text}{" "}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
{!done && (
|
||||
<span
|
||||
className="ml-0.5 inline-block h-3 w-0.5 translate-y-0.5 rounded-full bg-ink"
|
||||
style={{ animation: "fade-in 150ms ease-out both" }}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* action icons row */}
|
||||
<div
|
||||
className="mt-2 flex items-center gap-0.5 transition-opacity duration-400"
|
||||
style={{ opacity: done ? 1 : 0, pointerEvents: done ? "auto" : "none" }}
|
||||
>
|
||||
{ACTION_ICONS.map((icon, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
aria-label="Action"
|
||||
className="flex size-6 items-center justify-center rounded-[6px] text-ink-3
|
||||
transition-colors duration-100 hover:bg-hover-2 hover:text-ink-2"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
{icon}
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={sourcesOpen}
|
||||
onClick={() => setSourcesOpen((current) => !current)}
|
||||
className="ml-1.5 flex items-center gap-1.5 rounded-[6px] px-1 py-0.5 text-left transition-colors duration-150 hover:bg-hover"
|
||||
>
|
||||
<span className="flex -space-x-1">
|
||||
{sources.map((source) => (
|
||||
<img
|
||||
key={source.domain}
|
||||
src={sourceImage(source)}
|
||||
alt=""
|
||||
className="source-avatar size-3.5 rounded-full bg-surface shadow-[0_0_0_1.5px_var(--canvas)]"
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="text-[12px] text-ink-2">{l.sources}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="grid transition-[grid-template-rows,opacity] duration-300"
|
||||
style={{
|
||||
gridTemplateRows: done && sourcesOpen ? "1fr" : "0fr",
|
||||
opacity: done && sourcesOpen ? 1 : 0,
|
||||
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="mt-1.5 flex flex-col rounded-[10px] bg-inset p-1 shadow-hairline">
|
||||
{sources.map((source) => (
|
||||
<a
|
||||
key={source.domain}
|
||||
href={source.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 rounded-[6px] px-1.5 py-1 text-[12px] text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink"
|
||||
>
|
||||
<img src={sourceImage(source)} alt="" className="source-avatar size-4 rounded-[4px]" />
|
||||
<span className="animated-underline">{source.name}</span>
|
||||
<span className="ml-auto font-mono text-[10.5px] text-ink-3">{source.domain}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* follow-ups */}
|
||||
<div
|
||||
className="mt-2.5 transition-opacity duration-400"
|
||||
style={{ opacity: done ? 1 : 0, pointerEvents: done ? "auto" : "none" }}
|
||||
>
|
||||
<p className="text-[12px] font-medium text-ink-2">{l.followUps}</p>
|
||||
<div className="mt-0.5 flex flex-col">
|
||||
{followUps.map((text, i) => (
|
||||
<button
|
||||
key={text}
|
||||
onClick={() => onFollowUp?.(text, i)}
|
||||
className="-mx-1.5 flex items-center gap-2 rounded-[7px] border-b border-line
|
||||
px-1.5 py-1.5 text-left text-[12.5px] text-ink transition-colors
|
||||
duration-100 hover:bg-hover-2"
|
||||
style={
|
||||
done
|
||||
? { animation: `fade-up 350ms cubic-bezier(0.23,1,0.32,1) ${i * 90}ms both` }
|
||||
: { opacity: 0 }
|
||||
}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<path d="M9 10l-5 5 5 5" />
|
||||
<path d="M20 4v7a4 4 0 0 1-4 4H4" />
|
||||
</svg>
|
||||
{text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* THINKING — expandable agent trace, four variants
|
||||
*
|
||||
* Steps step list with spinner → muted checks
|
||||
* Reasoning prose reasoning that expands, then settles
|
||||
* Search web-search trace: query + sources read
|
||||
* Coding tool trace: files read, edits, commands
|
||||
*
|
||||
* The trace runs once, settles, and remains expandable.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
const STAGES = [800, 600, 1800, 2600, 1600];
|
||||
|
||||
function useSequence(steps: number[]) {
|
||||
const [stage, setStage] = useState(0);
|
||||
useEffect(() => {
|
||||
if (stage >= steps.length - 1) return;
|
||||
const t = setTimeout(() => setStage((s) => s + 1), steps[stage]);
|
||||
return () => clearTimeout(t);
|
||||
}, [stage, steps]);
|
||||
return stage;
|
||||
}
|
||||
|
||||
type Row = {
|
||||
primary: string;
|
||||
secondary?: string;
|
||||
mono?: boolean;
|
||||
add?: number;
|
||||
del?: number;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
const VARIANTS: Record<
|
||||
string,
|
||||
{ active: string; done: string; rows: Row[]; query?: string }
|
||||
> = {
|
||||
Steps: {
|
||||
active: "Thinking",
|
||||
done: "Thought for 4 seconds",
|
||||
rows: [
|
||||
{ primary: "Reading flavor briefs" },
|
||||
{ primary: "Scanning supplier lists" },
|
||||
{ primary: "Comparing tasting notes", secondary: "6 flavors" },
|
||||
{ primary: "Writing the scoop report" },
|
||||
],
|
||||
},
|
||||
Reasoning: {
|
||||
active: "Thinking",
|
||||
done: "Thought for 4 seconds",
|
||||
rows: [
|
||||
{ primary: "Summer demand spikes for stone-fruit flavors — peach and apricot lead." },
|
||||
{ primary: "I should check cone inventory before promoting a waffle-bowl special." },
|
||||
],
|
||||
},
|
||||
Search: {
|
||||
active: "Searching the web",
|
||||
done: "Searched the web",
|
||||
query: "best waffle cone supplier",
|
||||
rows: [
|
||||
{ primary: "Joy Cone", secondary: "joycone.com", href: "https://joycone.com/fs_products/waffle-cones/" },
|
||||
{ primary: "WebstaurantStore", secondary: "webstaurantstore.com", href: "https://www.webstaurantstore.com/ice-cream-shop-supplies.html" },
|
||||
{ primary: "The Konery", secondary: "thekonery.com", href: "https://www.thekonery.com/" },
|
||||
],
|
||||
},
|
||||
Coding: {
|
||||
active: "Running tools",
|
||||
done: "Ran 3 tools",
|
||||
rows: [
|
||||
{ primary: "Read", secondary: "flavors.ts", mono: true },
|
||||
{ primary: "Edit", secondary: "ChurnSchedule.tsx", mono: true, add: 74, del: 41 },
|
||||
{ primary: "Run", secondary: "npm run freeze", mono: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function Dot({ tone }: { tone: string }) {
|
||||
return (
|
||||
<span className={`flex size-3.5 shrink-0 items-center justify-center rounded-full text-white ${tone}`}>
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3.5 12h17M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const TONES = ["bg-accent", "bg-orange", "bg-green"];
|
||||
|
||||
export default function ThinkingState({ variant = "Steps", onSettled }: { variant?: string; onSettled?: () => void }) {
|
||||
const stage = useSequence(STAGES);
|
||||
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null);
|
||||
const [selectedTool, setSelectedTool] = useState<string | null>(null);
|
||||
const v = VARIANTS[variant] ?? VARIANTS.Steps;
|
||||
const autoExpanded = stage >= 1 && stage < 4;
|
||||
const expanded = manualExpanded ?? autoExpanded;
|
||||
const working = stage < 3;
|
||||
const visible = stage < 2 ? 0 : stage === 2 ? Math.min(2, v.rows.length) : v.rows.length;
|
||||
const traceRef = useRef<HTMLDivElement>(null);
|
||||
const [lineHeight, setLineHeight] = useState(0);
|
||||
useLayoutEffect(() => {
|
||||
if (traceRef.current) setLineHeight(traceRef.current.offsetHeight);
|
||||
}, [visible, expanded, variant, stage]);
|
||||
|
||||
/* let embedders sequence content after the trace settles */
|
||||
const settledRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (working || settledRef.current) return;
|
||||
settledRef.current = true;
|
||||
onSettled?.();
|
||||
}, [working, onSettled]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={variant}
|
||||
className="flex w-full max-w-95 flex-col"
|
||||
style={{
|
||||
minHeight: working || expanded ? 176 : undefined,
|
||||
transition: "min-height 400ms cubic-bezier(0.23,1,0.32,1)",
|
||||
}}
|
||||
>
|
||||
{/* header — shared across variants */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setManualExpanded((current) => !(current ?? autoExpanded))}
|
||||
className="-mx-1.5 flex w-fit items-center gap-2 rounded-control px-1.5 py-1
|
||||
transition-colors duration-100 hover:bg-hover-2"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill={working ? "var(--ink-2)" : "var(--ink-3)"}>
|
||||
<path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />
|
||||
</svg>
|
||||
<span role="status" className="contents">
|
||||
{working ? (
|
||||
<span
|
||||
className="bg-clip-text text-[13px] font-medium whitespace-nowrap text-transparent"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)",
|
||||
backgroundSize: "200% 100%",
|
||||
animation: "shimmer-text 1.4s linear infinite",
|
||||
}}
|
||||
>
|
||||
{v.active}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="text-[13px] font-medium whitespace-nowrap text-ink-2"
|
||||
style={{ animation: "fade-in 350ms ease-out both" }}
|
||||
>
|
||||
{v.done}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
|
||||
className="transition-transform duration-300"
|
||||
style={{ transform: expanded ? "rotate(180deg)" : "rotate(0)" }}
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* expandable trace */}
|
||||
<div
|
||||
className="grid transition-[grid-template-rows,opacity] duration-400"
|
||||
style={{
|
||||
gridTemplateRows: expanded ? "1fr" : "0fr",
|
||||
opacity: expanded ? 1 : 0,
|
||||
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="relative mt-1 ml-[5px] pl-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-[3px] w-px bg-line"
|
||||
style={{ top: -8, height: lineHeight ? lineHeight - 2 : 0, transition: "height 500ms cubic-bezier(0.23,1,0.32,1)" }}
|
||||
/>
|
||||
<div ref={traceRef} className="flex flex-col gap-1 py-1">
|
||||
{v.query && (
|
||||
<div className="flex h-6 items-center gap-2 px-1.5" style={{ animation: expanded ? "fade-up 300ms cubic-bezier(0.23,1,0.32,1) both" : undefined }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" className="shrink-0">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="M21 21l-4.3-4.3" />
|
||||
</svg>
|
||||
<span className="text-[12.5px] text-ink-2">{v.query}</span>
|
||||
</div>
|
||||
)}
|
||||
{v.rows.slice(0, visible).map((row, i) => {
|
||||
const content = (
|
||||
<>
|
||||
{variant === "Search" && <Dot tone={TONES[i % 3]} />}
|
||||
{variant === "Steps" && (
|
||||
i < visible - 1 || !working ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
) : (
|
||||
<span className="size-3 shrink-0 rounded-full border-[1.5px] border-line-strong border-t-ink-2" style={{ animation: "spin 700ms linear infinite" }} />
|
||||
)
|
||||
)}
|
||||
<span className={`min-w-0 truncate text-[12.5px] ${variant === "Reasoning" ? "whitespace-normal leading-relaxed text-ink-2" : "font-medium text-ink"} ${variant === "Search" ? "animated-underline" : ""}`}>
|
||||
{row.primary}
|
||||
</span>
|
||||
{row.secondary && (
|
||||
<span className={`shrink-0 text-[11.5px] text-ink-3 ${row.mono ? "font-mono" : ""}`}>
|
||||
{row.secondary}
|
||||
</span>
|
||||
)}
|
||||
{row.add !== undefined && (
|
||||
<span className="shrink-0 font-mono text-[11px] tabular-nums">
|
||||
<span className="text-green">+{row.add}</span>{" "}
|
||||
<span className="text-red">−{row.del}</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const rowClass = "flex min-h-7 w-full items-center gap-2 rounded-[6px] px-1.5 py-0.5 text-left";
|
||||
const animation = { animation: `fade-up 320ms cubic-bezier(0.23,1,0.32,1) ${i * 120}ms both` };
|
||||
|
||||
if (variant === "Search") {
|
||||
return (
|
||||
<a
|
||||
key={row.primary}
|
||||
href={row.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`${rowClass} transition-colors duration-150 hover:bg-hover`}
|
||||
style={animation}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "Coding") {
|
||||
const selected = selectedTool === row.primary;
|
||||
return (
|
||||
<button
|
||||
key={row.primary}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => setSelectedTool(selected ? null : row.primary)}
|
||||
className={`${rowClass} transition-colors duration-150 ${selected ? "bg-inset" : "hover:bg-hover"}`}
|
||||
style={animation}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={row.primary} className={rowClass} style={animation}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{variant === "Search" && stage >= 3 && (
|
||||
<span className="text-[12px] text-ink-3" style={{ animation: "fade-in 300ms ease-out both" }}>
|
||||
+7 more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* TOOL CHIPS
|
||||
* An agent run as compact rows: tool calls with inline
|
||||
* chips, then file-diff chips summarizing the edits.
|
||||
* Hover a row to reveal its chevron; every row expands
|
||||
* to show what the tool actually did.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
const STEP_MS = 700;
|
||||
|
||||
const Icons: Record<string, React.ReactNode> = {
|
||||
think: <path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />,
|
||||
write: <g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z" /></g>,
|
||||
run: <g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 17l6-5-6-5M12 19h8" /></g>,
|
||||
read: <g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></g>,
|
||||
};
|
||||
|
||||
export type ToolDetailLine = { text: string; tone?: "add" };
|
||||
|
||||
export type ToolStep = {
|
||||
icon: string;
|
||||
label: string;
|
||||
chip: string;
|
||||
mono: boolean;
|
||||
detailMono: boolean;
|
||||
detail: ToolDetailLine[];
|
||||
};
|
||||
|
||||
export type ToolDiff = { file: string; add: number; del: number };
|
||||
|
||||
export type ToolDiffLine = { text: string; tone: "add" | "del" | "ctx" };
|
||||
|
||||
export type ToolChipsLabels = {
|
||||
header: string;
|
||||
more: string;
|
||||
};
|
||||
|
||||
const DEFAULT_LABELS: ToolChipsLabels = {
|
||||
header: "4 tool calls, 2 messages",
|
||||
more: "+2 more",
|
||||
};
|
||||
|
||||
const ROWS: ToolStep[] = [
|
||||
{
|
||||
icon: "think", label: "Thinking", chip: "Planning the churn schedule…", mono: false, detailMono: false,
|
||||
detail: [
|
||||
{ text: "Weekend demand carries pistachio, so it churns first." },
|
||||
{ text: "Batch capacity leaves two evening freezer windows." },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: "write", label: "Write 204 lines", chip: "ChurnSchedule.tsx", mono: true, detailMono: true,
|
||||
detail: [
|
||||
{ text: "+ const windows = slots.filter((s) => s.temp <= -12)", tone: "add" },
|
||||
{ text: "+ return schedule(windows, { hero: \"pistachio\" })", tone: "add" },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: "run", label: "Rebuild and verify", chip: "npm run freeze", mono: true, detailMono: true,
|
||||
detail: [
|
||||
{ text: "✓ built in 1.2s" },
|
||||
{ text: "✓ 34 checks passed" },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: "read", label: "Read image", chip: "flavor-chart.png", mono: true, detailMono: false,
|
||||
detail: [
|
||||
{ text: "1280 × 720 · line chart, three summers." },
|
||||
{ text: "Mint chip trends up 12% through July." },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const DIFFS: ToolDiff[] = [
|
||||
{ file: "flavors.css", add: 13, del: 0 },
|
||||
{ file: "ChurnSchedule.tsx", add: 74, del: 41 },
|
||||
{ file: "menu.ts", add: 8, del: 2 },
|
||||
];
|
||||
|
||||
/* hovering a file chip opens its diff — green added, red removed */
|
||||
const DIFF_LINES: Record<string, ToolDiffLine[]> = {
|
||||
"flavors.css": [
|
||||
{ text: ".scoop-card {", tone: "ctx" },
|
||||
{ text: " gap: 14px;", tone: "del" },
|
||||
{ text: " gap: 12px;", tone: "add" },
|
||||
{ text: " container-type: inline-size;", tone: "add" },
|
||||
{ text: "}", tone: "ctx" },
|
||||
],
|
||||
"ChurnSchedule.tsx": [
|
||||
{ text: "const slots = coldSlots(week);", tone: "ctx" },
|
||||
{ text: "const windows = slots;", tone: "del" },
|
||||
{ text: "const windows = slots.filter(", tone: "add" },
|
||||
{ text: " (s) => s.temp <= -12,", tone: "add" },
|
||||
{ text: ");", tone: "add" },
|
||||
],
|
||||
"menu.ts": [
|
||||
{ text: "export const hero = \"mint-chip\";", tone: "del" },
|
||||
{ text: "export const hero = \"pistachio\";", tone: "add" },
|
||||
],
|
||||
};
|
||||
|
||||
export default function ToolChips({
|
||||
steps = ROWS,
|
||||
diffs = DIFFS,
|
||||
diffLines = DIFF_LINES,
|
||||
labels,
|
||||
className,
|
||||
onOpenChange,
|
||||
onToggleRow,
|
||||
}: {
|
||||
/** Accepted for gallery/registry parity; ToolChips has no visual variants. */
|
||||
variant?: string;
|
||||
steps?: ToolStep[];
|
||||
diffs?: ToolDiff[];
|
||||
diffLines?: Record<string, ToolDiffLine[]>;
|
||||
labels?: Partial<ToolChipsLabels>;
|
||||
className?: string;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onToggleRow?: (label: string, open: boolean) => void;
|
||||
} = {}) {
|
||||
const copy = { ...DEFAULT_LABELS, ...labels };
|
||||
const [step, setStep] = useState(0);
|
||||
const [open, setOpen] = useState(true);
|
||||
const [openRows, setOpenRows] = useState<Set<string>>(new Set());
|
||||
/* Rendered in a body portal so animated/translated reply wrappers cannot
|
||||
* redefine the fixed-position coordinate system. */
|
||||
const [preview, setPreview] = useState<{
|
||||
file: string;
|
||||
x: number;
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
} | null>(null);
|
||||
const openPreview = (file: string) => (event: React.SyntheticEvent) => {
|
||||
const rect = (event.currentTarget as Element).closest("[data-diffchip]")!.getBoundingClientRect();
|
||||
const previewHeight = 38 + (diffLines[file]?.length ?? 0) * 19;
|
||||
const fitsBelow = rect.bottom + 6 + previewHeight <= window.innerHeight - 12;
|
||||
setPreview({
|
||||
file,
|
||||
x: Math.max(12, Math.min(rect.left, window.innerWidth - 300)),
|
||||
...(fitsBelow
|
||||
? { top: rect.bottom + 6 }
|
||||
: { bottom: window.innerHeight - rect.top + 6 }),
|
||||
});
|
||||
};
|
||||
const closePreview = (file: string) => () =>
|
||||
setPreview((current) => (current?.file === file ? null : current));
|
||||
const total = steps.length + 1; // rows, then diff chips
|
||||
|
||||
useEffect(() => {
|
||||
if (step >= total) return;
|
||||
const t = setTimeout(() => setStep((s) => s + 1), STEP_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [step, total]);
|
||||
|
||||
const toggleRow = (label: string) =>
|
||||
setOpenRows((current) => {
|
||||
const next = new Set(current);
|
||||
next.has(label) ? next.delete(label) : next.add(label);
|
||||
onToggleRow?.(label, next.has(label));
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`min-h-[220px] w-full max-w-80 pb-1${className ? ` ${className}` : ""}`}>
|
||||
{/* collapsed run header */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() =>
|
||||
setOpen((current) => {
|
||||
onOpenChange?.(!current);
|
||||
return !current;
|
||||
})
|
||||
}
|
||||
className="-mx-1.5 flex w-fit items-center gap-1.5 rounded-control px-1.5 py-1 text-[12.5px] text-ink-2 transition-colors duration-100 hover:bg-hover-2"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" className="transition-transform duration-200" style={{ transform: open ? "rotate(0deg)" : "rotate(-90deg)" }}>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
<span className="tabular-nums">{copy.header}</span>
|
||||
</button>
|
||||
|
||||
{/* tool call rows */}
|
||||
<div className="grid transition-[grid-template-rows,opacity] duration-300" style={{ gridTemplateRows: open ? "1fr" : "0fr", opacity: open ? 1 : 0 }}>
|
||||
{/* -mx-1 + px-1.5 keeps content at the same x while giving the
|
||||
row hover pills room inside this overflow-hidden clip box */}
|
||||
<div className="-mx-1 overflow-hidden px-1.5 pb-1">
|
||||
<div className="mt-1.5 flex flex-col gap-1">
|
||||
{steps.slice(0, step).map((row) => {
|
||||
const rowOpen = openRows.has(row.label);
|
||||
return (
|
||||
<div key={row.label} style={{ animation: "fade-up 300ms cubic-bezier(0.23,1,0.32,1) both" }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={rowOpen}
|
||||
onClick={() => toggleRow(row.label)}
|
||||
className="group/row -mx-[3px] flex h-7 w-[calc(100%+6px)] min-w-0 items-center gap-2 rounded-control px-[3px] text-left transition-colors duration-100 hover:bg-hover-2"
|
||||
>
|
||||
<span className="relative flex size-4 shrink-0 items-center justify-center text-ink-3">
|
||||
<svg
|
||||
width="13" height="13" viewBox="0 0 24 24" fill={row.icon === "think" ? "currentColor" : "none"} stroke="currentColor"
|
||||
className={`transition-opacity duration-100 group-hover/row:opacity-0 ${rowOpen ? "opacity-0" : ""}`}
|
||||
>
|
||||
{Icons[row.icon]}
|
||||
</svg>
|
||||
<svg
|
||||
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
|
||||
className={`absolute transition-[opacity,transform] duration-150 group-hover/row:opacity-100 ${rowOpen ? "opacity-100" : "opacity-0"}`}
|
||||
style={{ transform: rowOpen ? "rotate(0deg)" : "rotate(-90deg)" }}
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="shrink-0 text-[12.5px] font-medium text-ink">{row.label}</span>
|
||||
<span
|
||||
className={`inline-flex h-5.5 min-w-0 flex-1 cursor-pointer items-center truncate rounded-chip bg-field px-1.5
|
||||
text-[11.5px] text-ink-2 shadow-hairline transition-colors duration-100 hover:bg-hover-2
|
||||
${row.mono ? "font-mono" : ""}`}
|
||||
>
|
||||
{row.chip}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* expanded detail */}
|
||||
<div
|
||||
className="grid transition-[grid-template-rows,opacity] duration-300"
|
||||
style={{ gridTemplateRows: rowOpen ? "1fr" : "0fr", opacity: rowOpen ? 1 : 0, transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)" }}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<div className="mt-0.5 mb-1 ml-2 flex flex-col gap-0.5 border-l border-line py-0.5 pl-3.5">
|
||||
{row.detail.map((line) => (
|
||||
<span
|
||||
key={line.text}
|
||||
className={`truncate text-[11.5px] leading-[1.6] ${row.detailMono ? "font-mono" : ""} ${line.tone === "add" ? "text-green" : "text-ink-2"}`}
|
||||
>
|
||||
{line.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* file-diff chips */}
|
||||
{step >= total && (
|
||||
<div className="mt-2.5 flex max-w-full flex-wrap gap-1.5 border-t border-line pt-2.5">
|
||||
{diffs.map((d, i) => (
|
||||
<span
|
||||
key={d.file}
|
||||
data-diffchip
|
||||
className="relative"
|
||||
onMouseEnter={openPreview(d.file)}
|
||||
onMouseLeave={closePreview(d.file)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={preview?.file === d.file}
|
||||
aria-label={`Show diff for ${d.file}`}
|
||||
onFocus={openPreview(d.file)}
|
||||
onBlur={closePreview(d.file)}
|
||||
className="inline-flex h-7 max-w-full items-center gap-2 rounded-chip
|
||||
bg-surface px-2 font-mono text-[11.5px] text-ink shadow-btn
|
||||
transition-colors duration-100 hover:bg-hover"
|
||||
style={{ animation: `pop-in 250ms cubic-bezier(0.23,1,0.32,1) ${i * 80}ms both` }}
|
||||
>
|
||||
<span className="min-w-0 truncate">{d.file}</span>
|
||||
<span className="shrink-0 text-green tabular-nums">+{d.add}</span>
|
||||
{d.del > 0 && <span className="shrink-0 text-red tabular-nums">−{d.del}</span>}
|
||||
</button>
|
||||
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 items-center rounded-chip px-1.5 font-mono text-[11.5px] text-ink-3
|
||||
underline decoration-transparent underline-offset-2 transition-colors duration-100
|
||||
hover:text-ink-2 hover:decoration-current"
|
||||
style={{ animation: `fade-in 300ms ease-out ${diffs.length * 80}ms both` }}
|
||||
>
|
||||
{copy.more}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{preview && typeof document !== "undefined" && createPortal(
|
||||
<div
|
||||
className="fixed z-50 w-72 overflow-hidden rounded-[10px] bg-surface shadow-overlay"
|
||||
style={{
|
||||
left: preview.x,
|
||||
top: preview.top,
|
||||
bottom: preview.bottom,
|
||||
animation: "pop-in 160ms cubic-bezier(0.23,1,0.32,1) both",
|
||||
transformOrigin: preview.top === undefined ? "bottom left" : "top left",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-line px-2.5 py-1.5 font-mono text-[11px]">
|
||||
<span className="min-w-0 truncate text-ink-2">{preview.file}</span>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
<span className="text-green">+{diffs.find((diff) => diff.file === preview.file)?.add}</span>
|
||||
{(diffs.find((diff) => diff.file === preview.file)?.del ?? 0) > 0 && (
|
||||
<span className="text-red"> −{diffs.find((diff) => diff.file === preview.file)?.del}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="py-1 font-mono text-[11px] leading-[1.8]">
|
||||
{(diffLines[preview.file] ?? []).map((line, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex gap-2 px-2.5 whitespace-pre ${
|
||||
line.tone === "add"
|
||||
? "bg-green-tint text-green"
|
||||
: line.tone === "del"
|
||||
? "bg-red-tint text-red"
|
||||
: "text-ink-2"
|
||||
}`}
|
||||
>
|
||||
<span className="w-3 shrink-0 select-none">{line.tone === "add" ? "+" : line.tone === "del" ? "−" : " "}</span>
|
||||
<span className="min-w-0 truncate">{line.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-643
@@ -1,643 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
/* shadow-plugin (flornkm) — smooth, layered shadow stacks. The unprefixed
|
||||
* entry maps them onto Tailwind's --shadow-xs…2xl scale; our semantic tokens
|
||||
* below build on those values so the whole app picks up the smooth shadows. */
|
||||
@import "shadow-plugin/unprefixed";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* GENERIC FOUNDATION — tokens, @theme, base rules, the primitive-*
|
||||
* spacing utilities, and shared keyframes below are needed by EVERY
|
||||
* component. Blocks tagged COMPONENT-SPECIFIC further down belong to
|
||||
* one primitive each. */
|
||||
/* ──────────────────────────────────────────────────────────
|
||||
* Beautiful UI — design tokens
|
||||
* Cool near-white canvas, white cards, hairline borders, layered
|
||||
* single-digit-opacity shadows, a neutral ink ramp, and semantic
|
||||
* color used sparingly as a condiment.
|
||||
* ────────────────────────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
/* surfaces — cool, blue-tinted neutrals */
|
||||
--page: oklch(0.985 0.001 286.376);
|
||||
--canvas: oklch(0.961 0.002 247.84);
|
||||
--surface: oklch(1 0 0);
|
||||
--inset: oklch(0.979 0.002 247.839);
|
||||
--hover: oklch(0.97 0.002 247.839);
|
||||
--hover-2: oklch(0.933 0.003 247.86);
|
||||
|
||||
/* ink ramp */
|
||||
--ink: oklch(0.247 0.006 258.361);
|
||||
--ink-2: oklch(0.506 0.01 264.477);
|
||||
--ink-3: oklch(0.695 0.009 264.505);
|
||||
|
||||
/* borders — solid and crisp, not alpha */
|
||||
--line: oklch(0.946 0.003 264.542);
|
||||
--line-strong: oklch(0.912 0.005 258.326);
|
||||
--line-soft: oklch(0.966 0.002 264.542);
|
||||
--grid-line: color-mix(in srgb, var(--line) 78%, transparent);
|
||||
--field: oklch(0.961 0.001 286.375);
|
||||
--stripe: oklch(0.405 0 0 / 0.075);
|
||||
--stripe-bg: oklch(0.97 0 0);
|
||||
|
||||
/* accent */
|
||||
--accent: oklch(0.626 0.205 254.947);
|
||||
--accent-ink: oklch(0.556 0.187 255.617);
|
||||
--accent-tint: oklch(0.96 0.019 252.878);
|
||||
|
||||
/* semantic */
|
||||
--green: oklch(0.603 0.155 150.883);
|
||||
--green-tint: oklch(0.958 0.017 159.118);
|
||||
--orange: oklch(0.689 0.179 49.902);
|
||||
--orange-tint: oklch(0.964 0.021 67.581);
|
||||
--red: oklch(0.621 0.192 23.042);
|
||||
--red-tint: oklch(0.956 0.017 17.462);
|
||||
|
||||
/* chart overlays — neutral system colors with enough contrast on either theme */
|
||||
--tooltip-bg: oklch(0.272 0.008 264.435);
|
||||
--tooltip-fg: oklch(0.976 0.002 247.839);
|
||||
--tooltip-muted: oklch(0.731 0.008 260.731);
|
||||
--tooltip-border: oklch(0.356 0.007 264.474);
|
||||
|
||||
/* shadows — our solid hairline ring + shadow-plugin's smooth layered stacks
|
||||
* (--shadow-xs…lg come from shadow-plugin/unprefixed) so every surface reads
|
||||
* consistently across the app. */
|
||||
--shadow-hairline: 0 0 0 1px var(--line);
|
||||
--shadow-btn: 0 0 0 1px var(--line-strong), var(--shadow-xs);
|
||||
--shadow-card: 0 0 0 1px var(--line), var(--shadow-sm);
|
||||
--shadow-raised: 0 0 0 1px var(--line), var(--shadow-md);
|
||||
--shadow-overlay: 0 0 0 1px var(--line), var(--shadow-lg);
|
||||
--shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.12);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--page: oklch(0.209 0.004 264.477);
|
||||
--canvas: oklch(0.231 0.004 264.487);
|
||||
--surface: oklch(0.26 0.006 271.191);
|
||||
--inset: oklch(0.243 0.004 264.492);
|
||||
--hover: oklch(0.289 0.006 271.22);
|
||||
--hover-2: oklch(0.318 0.007 274.747);
|
||||
|
||||
--ink: oklch(0.964 0.002 247.839);
|
||||
--ink-2: oklch(0.731 0.008 260.731);
|
||||
--ink-3: oklch(0.541 0.01 264.484);
|
||||
|
||||
--line: oklch(0.308 0.006 258.354);
|
||||
--line-strong: oklch(0.356 0.007 264.474);
|
||||
--line-soft: oklch(0.278 0.006 258.354);
|
||||
--field: oklch(0.293 0.006 271.223);
|
||||
--stripe: oklch(1 0 0 / 0.055);
|
||||
--stripe-bg: oklch(0.226 0.004 264.485);
|
||||
|
||||
--accent: oklch(0.68 0.173 253.301);
|
||||
--accent-ink: oklch(0.788 0.113 248.33);
|
||||
--accent-tint: oklch(0.68 0.173 253.301 / 0.16);
|
||||
|
||||
--green: oklch(0.705 0.154 153.814);
|
||||
--green-tint: oklch(0.705 0.154 153.814 / 0.14);
|
||||
--orange: oklch(0.746 0.156 55.642);
|
||||
--orange-tint: oklch(0.746 0.156 55.642 / 0.14);
|
||||
--red: oklch(0.666 0.18 21.433);
|
||||
--red-tint: oklch(0.666 0.18 21.433 / 0.14);
|
||||
|
||||
--tooltip-bg: oklch(0.182 0.004 264.459);
|
||||
--tooltip-fg: oklch(0.964 0.002 247.839);
|
||||
--tooltip-muted: oklch(0.731 0.008 260.731);
|
||||
--tooltip-border: oklch(0.308 0.006 258.354);
|
||||
|
||||
--shadow-hairline: 0 0 0 1px var(--line);
|
||||
/* Elevation rings go to a subtle *light* hairline in dark mode: the ring
|
||||
* paints over the surface behind the element, so a low-alpha white edge
|
||||
* dissolves into the shadow as one continuous stroke (per smooth-shadow-ring),
|
||||
* graduated by how far the surface floats. */
|
||||
--shadow-btn: 0 0 0 1px oklch(1 0 0 / 0.1), 0 1px 2px oklch(0 0 0 / 0.3);
|
||||
--shadow-card:
|
||||
0 0 0 1px oklch(1 0 0 / 0.11), 0 1px 2px oklch(0 0 0 / 0.2),
|
||||
0 2px 6px oklch(0 0 0 / 0.2);
|
||||
--shadow-raised:
|
||||
0 0 0 1px oklch(1 0 0 / 0.13), 0 2px 10px oklch(0 0 0 / 0.22);
|
||||
--shadow-overlay:
|
||||
0 0 0 1px oklch(1 0 0 / 0.15), 0 8px 28px oklch(0 0 0 / 0.34);
|
||||
--shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* colors */
|
||||
--color-page: var(--page);
|
||||
--color-canvas: var(--canvas);
|
||||
--color-surface: var(--surface);
|
||||
--color-inset: var(--inset);
|
||||
--color-ink: var(--ink);
|
||||
--color-ink-2: var(--ink-2);
|
||||
--color-ink-3: var(--ink-3);
|
||||
--color-hover: var(--hover);
|
||||
--color-hover-2: var(--hover-2);
|
||||
--color-field: var(--field);
|
||||
--color-line: var(--line);
|
||||
--color-line-strong: var(--line-strong);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-ink: var(--accent-ink);
|
||||
--color-accent-tint: var(--accent-tint);
|
||||
--color-green: var(--green);
|
||||
--color-green-tint: var(--green-tint);
|
||||
--color-orange: var(--orange);
|
||||
--color-orange-tint: var(--orange-tint);
|
||||
--color-red: var(--red);
|
||||
--color-red-tint: var(--red-tint);
|
||||
|
||||
/* shadows */
|
||||
--shadow-hairline: var(--shadow-hairline);
|
||||
--shadow-btn: var(--shadow-btn);
|
||||
--shadow-inset-field: var(--shadow-inset-field);
|
||||
--shadow-card: var(--shadow-card);
|
||||
--shadow-raised: var(--shadow-raised);
|
||||
--shadow-overlay: var(--shadow-overlay);
|
||||
|
||||
/* type */
|
||||
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: var(--font-mono-face), ui-monospace, "SF Mono", monospace;
|
||||
|
||||
/* radii — chips 6, controls 8, cards 10, windows 14, pills 999 */
|
||||
--radius-chip: 6px;
|
||||
--radius-control: 8px;
|
||||
--radius-card: 10px;
|
||||
--radius-window: 14px;
|
||||
|
||||
/* easing — strong curves; built-ins are too weak */
|
||||
--ease-out-strong: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
--ease-in-out-strong: cubic-bezier(0.77, 0, 0.175, 1);
|
||||
--ease-link: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* ── base ─────────────────────────────────────────────── */
|
||||
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--stripe-bg);
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent 0,
|
||||
transparent 7px,
|
||||
var(--stripe) 7px,
|
||||
var(--stripe) 8px
|
||||
);
|
||||
/* Pin the stripe pattern to the viewport so it stays put on scroll. */
|
||||
background-attachment: fixed;
|
||||
color: var(--ink);
|
||||
font-feature-settings: "cv11", "ss01";
|
||||
font-size: 14px;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-tint);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ── interface hygiene (interfaces.dev cheat sheet) ────── */
|
||||
|
||||
/* headings balance; descriptions avoid orphans (skip long-form — we have none) */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
p {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
/* underlines clear descenders */
|
||||
a {
|
||||
text-underline-position: from-font;
|
||||
text-decoration-skip-ink: auto;
|
||||
}
|
||||
|
||||
/* theme switches swap every token at once — freeze transitions so the flip
|
||||
* is one clean repaint instead of hundreds of mismatched color fades */
|
||||
.theme-switching *,
|
||||
.theme-switching *::before,
|
||||
.theme-switching *::after {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* touch: :hover sticks after a tap, so neutralize row/header hovers there */
|
||||
@media (hover: none) {
|
||||
.records-row:hover > .records-cell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.records-row:hover > td.is-colsel {
|
||||
background: color-mix(in srgb, var(--accent) 4%, var(--surface));
|
||||
}
|
||||
|
||||
.records-header-button:hover {
|
||||
background: transparent;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep the component nav scrollable but hide its scrollbar. */
|
||||
.component-nav-scroll {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* legacy Edge */
|
||||
}
|
||||
.component-nav-scroll::-webkit-scrollbar {
|
||||
display: none; /* WebKit */
|
||||
}
|
||||
|
||||
@media (min-width: 64rem) {
|
||||
.component-nav-scroll {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
oklch(0 0 0) 0,
|
||||
oklch(0 0 0) calc(100% - 6rem),
|
||||
transparent 100%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
oklch(0 0 0) 0,
|
||||
oklch(0 0 0) calc(100% - 6rem),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile demo gestures should operate controls and visualizations rather than
|
||||
* selecting surrounding labels. Text entry and code remain intentionally
|
||||
* selectable. */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.primitive-showcase {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.primitive-showcase :is(input, textarea, [contenteditable="true"], pre, code) {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
-webkit-touch-callout: default;
|
||||
}
|
||||
|
||||
.primitive-showcase :is(a, button, [role="button"]) {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.primitive-showcase :is(a, button, [role="button"]):focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.primitive-showcase :is(img, svg, canvas) {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.insight-chart-stage {
|
||||
touch-action: pan-y;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tailwind v4 resets buttons to cursor:default — anything
|
||||
* clickable must invite the click */
|
||||
button:not(:disabled),
|
||||
[role="button"]:not([aria-disabled="true"]),
|
||||
label:has(input),
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible,
|
||||
[contenteditable]:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.animated-underline {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.animated-underline::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
height: 1px;
|
||||
content: "";
|
||||
background: currentColor;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
transition: transform 280ms var(--ease-link);
|
||||
}
|
||||
|
||||
a:hover .animated-underline::after,
|
||||
a:focus-visible .animated-underline::after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* COMPONENT-SPECIFIC — StreamingText / ContextCards
|
||||
* Favicon source avatars.
|
||||
* Copy this block only alongside StreamingText / ContextCards; it is not part of the
|
||||
* generic foundation above.
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
.source-avatar {
|
||||
box-shadow: 0 0 0 1px oklch(0.21 0.034 263.436 / 0.1);
|
||||
}
|
||||
|
||||
.dark .source-avatar {
|
||||
box-shadow: 0 0 0 1px oklch(1 0 0 / 0.08);
|
||||
}
|
||||
|
||||
/* ── primitive spacing system ─────────────────────────────
|
||||
* Shared optical offsets for small demo cards. Use these
|
||||
* instead of hand-tuning 10/12/14px per component.
|
||||
*/
|
||||
|
||||
.primitive-card-pad {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.primitive-card-bar {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.primitive-card-footer {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.primitive-table-cell {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.primitive-icon-button {
|
||||
display: inline-flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
/* ── shared keyframes ─────────────────────────────────── */
|
||||
|
||||
@keyframes shimmer-text {
|
||||
from {
|
||||
background-position: 150% center;
|
||||
}
|
||||
to {
|
||||
background-position: -50% center;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.home-reveal {
|
||||
will-change: opacity, transform, filter;
|
||||
}
|
||||
|
||||
/* The sidebar is one persistent 224px tree clipped by a 52px shell.
|
||||
* Every rail glyph remains centered at x=26 in both states. */
|
||||
.records-footer-hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── filter table electric status colors ─────────────────── */
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* COMPONENT-SPECIFIC — FilterTable
|
||||
* Electric status-pill colors.
|
||||
* Copy this block only alongside FilterTable; it is not part of the
|
||||
* generic foundation above.
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
.filter-status-todo,
|
||||
.filter-status-progress,
|
||||
.filter-status-done {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* COMPONENT-SPECIFIC — InsightCards
|
||||
* Liveline chart tooltip + cursor.
|
||||
* Copy this block only alongside InsightCards; it is not part of the
|
||||
* generic foundation above.
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
.insight-chart-stage {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.insight-chart-cursor {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--ink);
|
||||
opacity: 0.26;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.insight-chart-tooltip-anchor {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 8px;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.insight-chart-tooltip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 8px;
|
||||
color: var(--tooltip-fg);
|
||||
background: var(--tooltip-bg);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
font-size: 11.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.insight-chart-tooltip-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.insight-chart-tooltip-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex: 0 0 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Status pills share the same base-hue → color-mix derivation as .records-tag,
|
||||
* so both components stay consistent across light and dark. */
|
||||
.filter-status-todo {
|
||||
--tag-base: oklch(0.757 0.153 66.401);
|
||||
}
|
||||
.filter-status-progress {
|
||||
--tag-base: oklch(0.671 0.118 219.351);
|
||||
}
|
||||
.filter-status-done {
|
||||
--tag-base: oklch(0.652 0.131 162.865);
|
||||
}
|
||||
|
||||
.filter-status-todo,
|
||||
.filter-status-progress,
|
||||
.filter-status-done {
|
||||
color: color-mix(in srgb, var(--tag-base) 92%, var(--ink));
|
||||
background: color-mix(in srgb, var(--tag-base) 20%, var(--surface));
|
||||
border-color: color-mix(in srgb, var(--tag-base) 34%, var(--surface));
|
||||
}
|
||||
|
||||
/* Dark mode — a richer, saturated same-hue fill
|
||||
* with bright same-hue text and no visible border (light mode keeps the soft
|
||||
* tinted pastels above). Applies to both the records grid and status pills. */
|
||||
.dark .records-tag,
|
||||
.dark .filter-status-todo,
|
||||
.dark .filter-status-progress,
|
||||
.dark .filter-status-done {
|
||||
color: color-mix(in srgb, var(--tag-base) 86%, white);
|
||||
background: color-mix(in srgb, var(--tag-base) 34%, var(--surface));
|
||||
border-color: color-mix(in srgb, var(--tag-base) 34%, var(--surface));
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes eq-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(0.35);
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── StreamText primitive ─────────────────────────────────
|
||||
* Fast character reveal: the newest few characters carry a
|
||||
* soft, masked blur so the leading edge resolves smoothly as
|
||||
* text flows in. The caret holds solid while streaming, then
|
||||
* blinks once the string settles. */
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* COMPONENT-SPECIFIC — StreamText
|
||||
* The streaming caret + tail.
|
||||
* Copy this block only alongside StreamText; it is not part of the
|
||||
* generic foundation above.
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
.stream-tail {
|
||||
filter: blur(1.6px);
|
||||
-webkit-mask-image: linear-gradient(to right, oklch(0 0 0) 20%, oklch(0 0 0 / 0.2));
|
||||
mask-image: linear-gradient(to right, oklch(0 0 0) 20%, oklch(0 0 0 / 0.2));
|
||||
}
|
||||
|
||||
.stream-caret {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 1.05em;
|
||||
margin-left: 1.5px;
|
||||
translate: 0 -0.5px;
|
||||
border-radius: 1px;
|
||||
background: var(--ink);
|
||||
vertical-align: text-bottom;
|
||||
animation: caret-blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
.stream-caret.is-streaming {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes caret-blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.stream-caret {
|
||||
animation: none;
|
||||
}
|
||||
.stream-tail {
|
||||
filter: none;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* pixel-grid loader — brighten plateau, staggered per cell */
|
||||
@keyframes pixel-on {
|
||||
0%, 100% {
|
||||
opacity: 0.15;
|
||||
}
|
||||
18%, 42% {
|
||||
opacity: 1;
|
||||
}
|
||||
62% {
|
||||
opacity: 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* COMPONENT-SPECIFIC — SidebarNav
|
||||
* The 52↔224px collapsible sidebar tree.
|
||||
* Copy this block only alongside SidebarNav; it is not part of the
|
||||
* generic foundation above.
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
.sidebar-row {
|
||||
width: 208px;
|
||||
transition-duration: var(--sidebar-copy-duration);
|
||||
transition-timing-function: var(--sidebar-easing);
|
||||
}
|
||||
|
||||
.sidebar-copy {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transition:
|
||||
opacity var(--sidebar-copy-duration) ease-out,
|
||||
transform var(--sidebar-copy-duration) var(--sidebar-easing);
|
||||
}
|
||||
|
||||
.sidebar-logo,
|
||||
.sidebar-collapse-control,
|
||||
.sidebar-expand-control {
|
||||
transition-duration: var(--sidebar-copy-duration);
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.sidebar-expand-control {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar-glide-highlight {
|
||||
right: 8px;
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed="true"] .sidebar-row {
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed="true"] .sidebar-copy {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateX(calc(var(--sidebar-copy-offset) * -1));
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed="true"] .sidebar-workspace-control {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed="true"] .sidebar-logo,
|
||||
[data-sidebar-collapsed="true"] .sidebar-collapse-control {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed="true"] .sidebar-expand-control {
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed="true"] .sidebar-glide-highlight {
|
||||
right: auto;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.home-reveal {
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── records table ────────────────────────────────────────
|
||||
* The grid is deliberately more architectural than card-like:
|
||||
* one quiet surface, precise rules, and color reserved for
|
||||
* meaning. The first column remains an anchor while both axes
|
||||
* can move independently inside the table viewport.
|
||||
*/
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "stream-text",
|
||||
"type": "registry:ui",
|
||||
"title": "StreamText",
|
||||
"description": "Shared building block used by other Beautiful UI components.",
|
||||
"registryDependencies": [
|
||||
"https://www.beautifului.dev/r/foundation.json"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "components/atoms/StreamText.tsx",
|
||||
"content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * STREAM TEXT — reusable streaming primitive\n * Reveals characters quickly (fast, like a real token stream);\n * the leading edge resolves out of a soft blur, and the caret\n * stays solid while streaming, then blinks once the text\n * settles. Inherits typography from its context, so it drops\n * into any text surface (Selection Actions, chat, etc.).\n * ───────────────────────────────────────────────────────── */\n\nexport function StreamText({\n text,\n charsPerTick = 2,\n tickMs = 9,\n blurTail = 6,\n caret = true,\n className,\n onProgress,\n onDone,\n}: {\n text: string;\n /** characters revealed per tick — higher is faster */\n charsPerTick?: number;\n /** interval between reveals, ms */\n tickMs?: number;\n /** how many trailing characters carry the soft blur edge */\n blurTail?: number;\n /** render the caret (solid while streaming, blinks once idle) */\n caret?: boolean;\n className?: string;\n /** fires each tick — useful for re-anchoring UI to reflowing text */\n onProgress?: () => void;\n /** fires once the full string is shown */\n onDone?: () => void;\n}) {\n const [count, setCount] = useState(0);\n const onProgressRef = useRef(onProgress);\n const onDoneRef = useRef(onDone);\n onProgressRef.current = onProgress;\n onDoneRef.current = onDone;\n\n useEffect(() => {\n setCount(0);\n let i = 0;\n const id = setInterval(() => {\n i = Math.min(i + charsPerTick, text.length);\n setCount(i);\n onProgressRef.current?.();\n if (i >= text.length) {\n clearInterval(id);\n onDoneRef.current?.();\n }\n }, tickMs);\n return () => clearInterval(id);\n }, [text, charsPerTick, tickMs]);\n\n const streaming = count < text.length;\n const shown = text.slice(0, count);\n const split = streaming ? Math.max(0, shown.length - blurTail) : shown.length;\n\n return (\n <span className={className}>\n {shown.slice(0, split)}\n {split < shown.length && (\n <span className=\"stream-tail\">{shown.slice(split)}</span>\n )}\n {caret && (\n <span\n aria-hidden\n className={`stream-caret${streaming ? \" is-streaming\" : \"\"}`}\n />\n )}\n </span>\n );\n}\n",
|
||||
"type": "registry:ui",
|
||||
"target": "components/atoms/StreamText.tsx"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,18 +0,0 @@
|
||||
// 校验 react-icons/lu 里要用的图标名是否存在
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
|
||||
const pnpm = new URL('../node_modules/.pnpm/', import.meta.url)
|
||||
const dir = readdirSync(pnpm).find((d) => d.startsWith('react-icons@'))
|
||||
if (!dir) throw new Error('react-icons not found')
|
||||
const dts = new URL(`${dir}/node_modules/react-icons/lu/index.d.ts`, pnpm)
|
||||
const content = readFileSync(dts, 'utf8')
|
||||
|
||||
const names = [
|
||||
'LuCheck', 'LuCpu', 'LuMessageSquare', 'LuPencil', 'LuRefreshCw', 'LuSettings', 'LuStore',
|
||||
'LuTrash2', 'LuX', 'LuAlertTriangle', 'LuTriangleAlert', 'LuRotateCw', 'LuShieldAlert',
|
||||
'LuTimer', 'LuKeyRound', 'LuLogOut', 'LuSearch', 'LuSparkles', 'LuPlus', 'LuArrowUp',
|
||||
'LuSquare', 'LuChevronDown', 'LuActivity'
|
||||
]
|
||||
for (const n of names) {
|
||||
console.log(n, content.includes(`declare const ${n}:`) ? 'OK' : 'MISSING')
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// 补抓 thinking-state + 解包全部 JSON 到暂存目录,并打印每个组件的 import / 导出签名
|
||||
import { mkdir, writeFile, readFile } from 'node:fs/promises'
|
||||
|
||||
const jsonDir = new URL('./beautifului/', import.meta.url)
|
||||
const stageDir = new URL('./beautifului/stage/', import.meta.url)
|
||||
await mkdir(stageDir, { recursive: true })
|
||||
|
||||
// 1) 补抓 thinking-state
|
||||
const tsRes = await fetch('https://www.beautifului.dev/r/thinking-state.json')
|
||||
if (tsRes.ok) await writeFile(new URL('thinking-state.json', jsonDir), await tsRes.text())
|
||||
else console.log('thinking-state => HTTP', tsRes.status)
|
||||
|
||||
// 2) 解包 + 签名摘要
|
||||
const slugs = [
|
||||
'foundation',
|
||||
'loading-state',
|
||||
'thinking-state',
|
||||
'streaming-text',
|
||||
'tool-chips',
|
||||
'approval-card',
|
||||
'button',
|
||||
'glide-menu'
|
||||
]
|
||||
|
||||
for (const s of slugs) {
|
||||
let raw
|
||||
try {
|
||||
raw = await readFile(new URL(`${s}.json`, jsonDir), 'utf8')
|
||||
} catch {
|
||||
console.log(`### ${s}: JSON 缺失`)
|
||||
continue
|
||||
}
|
||||
const j = JSON.parse(raw)
|
||||
console.log(`\n### ${s} (${j.type}) deps=${JSON.stringify(j.dependencies || [])}`)
|
||||
if (j.css) {
|
||||
await writeFile(new URL(`${s}.css`, stageDir), j.css)
|
||||
console.log(` css -> stage/${s}.css (${j.css.length} chars)`)
|
||||
}
|
||||
for (const f of j.files || []) {
|
||||
const name = f.path.split('/').pop()
|
||||
await writeFile(new URL(name, stageDir), f.content ?? '')
|
||||
console.log(` ${f.path} -> stage/${name} (${(f.content ?? '').length} chars)`)
|
||||
if (f.content) {
|
||||
const imports = f.content
|
||||
.split('\n')
|
||||
.filter((l) => /^(import|export)\b/.test(l.trim()) && /from ['"]/.test(l))
|
||||
for (const l of imports) console.log(' ', l.trim())
|
||||
const exports = f.content.match(/^export (function|const|interface|type|default)[^\n({]*/gm) || []
|
||||
for (const e of exports) console.log(' EXP:', e.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// 下载 beautifului.dev registry:索引 + 候选组件 JSON,保存到 scripts/beautifului/
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
|
||||
const outDir = new URL('./beautifului/', import.meta.url)
|
||||
await mkdir(outDir, { recursive: true })
|
||||
|
||||
async function grab(slug) {
|
||||
const r = await fetch(`https://www.beautifului.dev/r/${slug}.json`)
|
||||
if (!r.ok) {
|
||||
console.log(slug, '=> HTTP', r.status)
|
||||
return null
|
||||
}
|
||||
const text = await r.text()
|
||||
await writeFile(new URL(`${slug}.json`, outDir), text)
|
||||
return JSON.parse(text)
|
||||
}
|
||||
|
||||
// 1) registry 索引
|
||||
const idx = await grab('registry')
|
||||
if (idx) {
|
||||
const items = Array.isArray(idx) ? idx : idx.items || []
|
||||
console.log('=== registry index ===')
|
||||
for (const it of items) console.log('-', it.name, '|', it.title || '', '|', it.type || '')
|
||||
}
|
||||
|
||||
// 2) 候选组件(含传递依赖)
|
||||
const wanted = [
|
||||
'foundation',
|
||||
'loading-state',
|
||||
'streaming-text',
|
||||
'tool-chips',
|
||||
'approval-card',
|
||||
'button',
|
||||
'glide-menu',
|
||||
'task-rows',
|
||||
'prompt-bar',
|
||||
'selection-actions',
|
||||
'shimmer',
|
||||
'stream-text'
|
||||
]
|
||||
console.log('=== components ===')
|
||||
for (const s of wanted) {
|
||||
const j = await grab(s)
|
||||
if (!j) continue
|
||||
const files = (j.files || []).map((f) => f.path).join(', ')
|
||||
console.log(`${s}: deps=${JSON.stringify(j.dependencies || [])} files=[${files}]`)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// 把 registry 原版组件复制进 renderer(保持 beautifului.dev 原始源码)
|
||||
import { mkdir, copyFile } from 'node:fs/promises'
|
||||
|
||||
const stage = new URL('./beautifului/stage/', import.meta.url)
|
||||
const root = new URL('../apps/desktop/src/renderer/src/components/', import.meta.url)
|
||||
|
||||
await mkdir(new URL('atoms/', root), { recursive: true })
|
||||
await mkdir(new URL('primitives/', root), { recursive: true })
|
||||
|
||||
const verbatim = [
|
||||
['Button.tsx', 'atoms/Button.tsx'],
|
||||
['GlideMenu.tsx', 'primitives/GlideMenu.tsx'],
|
||||
['LoadingState.tsx', 'primitives/LoadingState.tsx'],
|
||||
['ApprovalCard.tsx', 'primitives/ApprovalCard.tsx']
|
||||
]
|
||||
|
||||
for (const [from, to] of verbatim) {
|
||||
await copyFile(new URL(from, stage), new URL(to, root))
|
||||
console.log('copied', from, '->', to)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// 检查 pi-coding-agent 的 SessionManager / AgentSession 类型定义,确认会话落盘时机
|
||||
import { readdirSync, readFileSync, existsSync } from 'node:fs'
|
||||
|
||||
const pnpm = new URL('../node_modules/.pnpm/', import.meta.url)
|
||||
const dir = readdirSync(pnpm).find((d) => d.startsWith('@earendil-works+pi-coding-agent@'))
|
||||
if (!dir) throw new Error('pi-coding-agent not found')
|
||||
const dist = new URL(`${dir}/node_modules/@earendil-works/pi-coding-agent/dist/`, pnpm)
|
||||
console.log('dist exists:', existsSync(dist))
|
||||
|
||||
// 在 dist 里 grep 关键定义
|
||||
const { execSync } = await import('node:child_process')
|
||||
const distPath = new URL('.', dist).pathname.replace(/^\/([A-Za-z]:)/, '$1')
|
||||
const grep = (pattern) => {
|
||||
try {
|
||||
const out = execSync(
|
||||
`findstr /s /n /c:"${pattern}" "${distPath}*.d.ts"`,
|
||||
{ encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }
|
||||
)
|
||||
console.log(`\n=== ${pattern} ===`)
|
||||
console.log(out.split('\n').slice(0, 40).join('\n'))
|
||||
} catch (e) {
|
||||
console.log(`\n=== ${pattern} === (no match)`)
|
||||
}
|
||||
}
|
||||
|
||||
grep('static list(')
|
||||
grep('sessionFile')
|
||||
grep('get sessionName')
|
||||
grep('setSessionName')
|
||||
grep('static continueRecent')
|
||||
grep('class SessionManager')
|
||||
grep('interface SessionInfo')
|
||||
Reference in New Issue
Block a user