feat: bootstrap secure cross-platform assistant

Establish the Electron foundation and pluggable agent runtime so desktop workflows can evolve safely across supported platforms.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-29 22:42:30 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit 1b6178b841
30 changed files with 12017 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<title>GoodBuddy</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+79
View File
@@ -0,0 +1,79 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentEvent, DesktopApi } from '../../shared/contracts'
import App from './App'
let agentListener: ((event: AgentEvent) => void) | undefined
const run = vi.fn<DesktopApi['agent']['run']>()
const api: DesktopApi = {
app: {
getInfo: vi.fn(async () => ({
name: 'GoodBuddy',
version: '0.1.0',
platform: 'win32',
arch: 'x64',
shortcut: 'CommandOrControl+Shift+Space'
})),
show: vi.fn(async () => {}),
hide: vi.fn(async () => {}),
onNewConversation: vi.fn(() => () => {})
},
agent: {
getStatus: vi.fn<DesktopApi['agent']['getStatus']>(async () => ({
id: 'demo' as const,
label: '演示模式',
available: true,
detail: 'Ready'
})),
run,
cancel: vi.fn(async () => {}),
onEvent: vi.fn((listener) => {
agentListener = listener
return () => {
agentListener = undefined
}
})
}
}
describe('App', () => {
beforeEach(() => {
localStorage.clear()
run.mockReset()
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: api
})
})
it('sends a prompt and renders streamed agent content', async () => {
render(<App />)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '帮我分析项目' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
expect(request?.prompt).toBe('帮我分析项目')
act(() => {
if (!request) {
throw new Error('Missing request')
}
agentListener?.({
requestId: request.requestId,
type: 'text',
delta: '这是回答内容'
})
agentListener?.({
requestId: request.requestId,
type: 'done'
})
})
expect(await screen.findByText('这是回答内容')).toBeInTheDocument()
})
})
+567
View File
@@ -0,0 +1,567 @@
import {
Bot,
ChevronDown,
CircleHelp,
FileText,
History,
Library,
MessageSquarePlus,
MoreHorizontal,
Paperclip,
Search,
Send,
Settings,
ShieldCheck,
Sparkles,
Square,
TerminalSquare,
UserRound
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type {
AgentEvent,
AgentRuntimeStatus,
AppInfo
} from '../../shared/contracts'
type ToolActivity = {
name: string
state: 'pending' | 'running' | 'completed' | 'failed'
summary: string
}
type Message = {
id: string
role: 'user' | 'assistant'
content: string
createdAt: number
state: 'streaming' | 'complete' | 'error'
status?: string
tools?: ToolActivity[]
}
type Conversation = {
id: string
title: string
updatedAt: number
messages: Message[]
}
type ActiveRun = {
conversationId: string
messageId: string
}
const storageKey = 'goodbuddy.conversations.v1'
const quickActions = [
{
title: '总结一段内容',
description: '提炼重点并输出行动项',
prompt: '请帮我总结下面的内容,并列出重点和行动项:\n'
},
{
title: '分析错误信息',
description: '定位原因并给出排查步骤',
prompt: '请分析下面的错误信息,给出可能原因和排查步骤:\n'
},
{
title: '编写工作内容',
description: '起草邮件、周报或方案',
prompt: '请帮我起草一份清晰、专业的工作内容:\n'
}
]
function createConversation(): Conversation {
const now = Date.now()
return {
id: crypto.randomUUID(),
title: '新对话',
updatedAt: now,
messages: [
{
id: crypto.randomUUID(),
role: 'assistant',
content:
'你好,我是 GoodBuddy。你可以直接向我提问,后续还可以让我读取经过授权的文件、搜索项目并调用工具。',
createdAt: now,
state: 'complete'
}
]
}
}
function loadConversations(): Conversation[] {
try {
const value = localStorage.getItem(storageKey)
if (!value) {
return [createConversation()]
}
const parsed: unknown = JSON.parse(value)
return Array.isArray(parsed) && parsed.length > 0
? (parsed as Conversation[])
: [createConversation()]
} catch {
return [createConversation()]
}
}
function formatTime(timestamp: number): string {
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit'
}).format(timestamp)
}
function App(): React.JSX.Element {
const [conversations, setConversations] = useState(loadConversations)
const [activeId, setActiveId] = useState(() => conversations[0]?.id ?? '')
const [input, setInput] = useState('')
const [runtime, setRuntime] = useState<AgentRuntimeStatus>()
const [appInfo, setAppInfo] = useState<AppInfo>()
const [sidebarOpen, setSidebarOpen] = useState(true)
const activeRuns = useRef(new Map<string, ActiveRun>())
const inputRef = useRef<HTMLTextAreaElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const activeConversation = useMemo(
() => conversations.find((conversation) => conversation.id === activeId),
[activeId, conversations]
)
const updateMessage = useCallback(
(
conversationId: string,
messageId: string,
update: (message: Message) => Message
): void => {
setConversations((current) =>
current.map((conversation) =>
conversation.id === conversationId
? {
...conversation,
updatedAt: Date.now(),
messages: conversation.messages.map((message) =>
message.id === messageId ? update(message) : message
)
}
: conversation
)
)
},
[]
)
const handleAgentEvent = useCallback(
(event: AgentEvent): void => {
const run = activeRuns.current.get(event.requestId)
if (!run) {
return
}
if (event.type === 'text') {
updateMessage(run.conversationId, run.messageId, (message) => ({
...message,
content: message.content + event.delta,
status: undefined
}))
} else if (event.type === 'status') {
updateMessage(run.conversationId, run.messageId, (message) => ({
...message,
status: event.message
}))
} else if (event.type === 'tool') {
updateMessage(run.conversationId, run.messageId, (message) => {
const tools = [...(message.tools ?? [])]
const index = tools.findIndex((tool) => tool.name === event.name)
const tool = {
name: event.name,
state: event.state,
summary: event.summary
}
if (index >= 0) {
tools[index] = tool
} else {
tools.push(tool)
}
return { ...message, tools }
})
} else {
updateMessage(run.conversationId, run.messageId, (message) => ({
...message,
state: event.type === 'error' ? 'error' : 'complete',
status: event.type === 'error' ? event.message : undefined,
content:
event.type === 'error' && !message.content
? event.message
: message.content
}))
activeRuns.current.delete(event.requestId)
}
},
[updateMessage]
)
useEffect(() => {
localStorage.setItem(storageKey, JSON.stringify(conversations))
}, [conversations])
useEffect(() => {
void window.goodbuddy.agent.getStatus().then(setRuntime)
void window.goodbuddy.app.getInfo().then(setAppInfo)
const removeAgentListener =
window.goodbuddy.agent.onEvent(handleAgentEvent)
const removeNewConversationListener =
window.goodbuddy.app.onNewConversation(() => {
const conversation = createConversation()
setConversations((current) => [conversation, ...current])
setActiveId(conversation.id)
inputRef.current?.focus()
})
return () => {
removeAgentListener()
removeNewConversationListener()
}
}, [handleAgentEvent])
useEffect(() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: 'smooth'
})
}, [activeConversation?.messages])
const newConversation = (): void => {
const conversation = createConversation()
setConversations((current) => [conversation, ...current])
setActiveId(conversation.id)
setInput('')
inputRef.current?.focus()
}
const submit = async (): Promise<void> => {
const prompt = input.trim()
if (!prompt || !activeConversation) {
return
}
const requestId = crypto.randomUUID()
const userMessage: Message = {
id: crypto.randomUUID(),
role: 'user',
content: prompt,
createdAt: Date.now(),
state: 'complete'
}
const assistantMessage: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: '',
createdAt: Date.now(),
state: 'streaming',
status: '正在连接 Agent Runtime'
}
const conversationId = activeConversation.id
activeRuns.current.set(requestId, {
conversationId,
messageId: assistantMessage.id
})
setConversations((current) =>
current.map((conversation) =>
conversation.id === conversationId
? {
...conversation,
title:
conversation.title === '新对话'
? prompt.slice(0, 24)
: conversation.title,
updatedAt: Date.now(),
messages: [
...conversation.messages,
userMessage,
assistantMessage
]
}
: conversation
)
)
setInput('')
try {
await window.goodbuddy.agent.run({
requestId,
conversationId,
prompt
})
} catch (error) {
handleAgentEvent({
requestId,
type: 'error',
message: error instanceof Error ? error.message : '发送失败'
})
}
}
const stop = async (): Promise<void> => {
const requestId = [...activeRuns.current.entries()].find(
([, run]) => run.conversationId === activeId
)?.[0]
if (requestId) {
await window.goodbuddy.agent.cancel(requestId)
}
}
const isRunning =
activeConversation?.messages.some(
(message) => message.state === 'streaming'
) ?? false
return (
<div className="app-shell">
<aside className={sidebarOpen ? 'sidebar' : 'sidebar sidebar--closed'}>
<div className="brand">
<div className="brand__mark">
<Bot size={20} strokeWidth={2.4} />
</div>
<div className="brand__copy">
<strong>GoodBuddy</strong>
<span>AI desktop companion</span>
</div>
</div>
<button className="new-chat" type="button" onClick={newConversation}>
<MessageSquarePlus size={17} />
<span></span>
<kbd>Ctrl N</kbd>
</button>
<div className="sidebar-search">
<Search size={15} />
<input aria-label="搜索对话" placeholder="搜索对话" />
</div>
<nav className="primary-nav" aria-label="主导航">
<button className="nav-item nav-item--active" type="button">
<History size={17} />
<span></span>
</button>
<button className="nav-item" type="button">
<Library size={17} />
<span></span>
<span className="nav-item__hint"></span>
</button>
<button className="nav-item" type="button">
<TerminalSquare size={17} />
<span></span>
<span className="nav-item__hint"></span>
</button>
</nav>
<div className="conversation-list">
<p className="section-label"></p>
{conversations.map((conversation) => (
<button
className={
conversation.id === activeId
? 'conversation-item conversation-item--active'
: 'conversation-item'
}
key={conversation.id}
type="button"
onClick={() => setActiveId(conversation.id)}
>
<span>{conversation.title}</span>
<small>{formatTime(conversation.updatedAt)}</small>
</button>
))}
</div>
<div className="sidebar-footer">
<button className="user-card" type="button">
<span className="avatar">GB</span>
<span className="user-card__copy">
<strong></strong>
<small>{appInfo ? `${appInfo.platform} · ${appInfo.arch}` : '加载中'}</small>
</span>
<Settings size={16} />
</button>
</div>
</aside>
<main className="workspace">
<header className="topbar">
<button
className="icon-button sidebar-toggle"
type="button"
aria-label="切换侧栏"
onClick={() => setSidebarOpen((open) => !open)}
>
<MoreHorizontal size={19} />
</button>
<button className="conversation-title" type="button">
<span>{activeConversation?.title ?? '新对话'}</span>
<ChevronDown size={15} />
</button>
<div className="topbar__actions">
<span
className={
runtime?.available
? 'runtime-status runtime-status--online'
: 'runtime-status'
}
title={runtime?.detail}
>
<span className="runtime-status__dot" />
{runtime?.label ?? '正在检测运行时'}
</span>
<button className="icon-button" type="button" aria-label="安全状态">
<ShieldCheck size={18} />
</button>
<button className="icon-button" type="button" aria-label="帮助">
<CircleHelp size={18} />
</button>
</div>
</header>
<section className="chat" ref={scrollRef}>
{activeConversation?.messages.length === 1 && (
<div className="welcome">
<div className="welcome__badge">
<Sparkles size={18} />
</div>
<p className="eyebrow">GOODBUDDY WORKSPACE</p>
<h1></h1>
<p className="welcome__description">
OpenCode 使
</p>
<div className="quick-actions">
{quickActions.map((action) => (
<button
key={action.title}
type="button"
onClick={() => {
setInput(action.prompt)
inputRef.current?.focus()
}}
>
<span className="quick-actions__icon">
<FileText size={17} />
</span>
<strong>{action.title}</strong>
<small>{action.description}</small>
</button>
))}
</div>
</div>
)}
<div className="message-list">
{activeConversation?.messages.map((message) => (
<article
className={`message message--${message.role}`}
key={message.id}
>
<div className="message__avatar">
{message.role === 'assistant' ? (
<Bot size={18} />
) : (
<UserRound size={18} />
)}
</div>
<div className="message__body">
<div className="message__meta">
<strong>
{message.role === 'assistant' ? 'GoodBuddy' : '你'}
</strong>
<span>{formatTime(message.createdAt)}</span>
</div>
{message.content && (
<div className="message__content">{message.content}</div>
)}
{message.tools?.map((tool) => (
<div className="tool-activity" key={tool.name}>
<TerminalSquare size={15} />
<span>{tool.summary}</span>
<small>{tool.state}</small>
</div>
))}
{message.status && (
<div
className={
message.state === 'error'
? 'message__status message__status--error'
: 'message__status'
}
>
<span className="thinking-dot" />
{message.status}
</div>
)}
</div>
</article>
))}
</div>
</section>
<footer className="composer-wrap">
<div className="composer">
<textarea
aria-label="向 GoodBuddy 提问"
placeholder="给 GoodBuddy 发消息…"
ref={inputRef}
rows={1}
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
void submit()
}
}}
/>
<div className="composer__toolbar">
<div className="composer__attachments">
<button type="button" aria-label="添加附件" title="下一阶段开放">
<Paperclip size={18} />
</button>
<span className="divider" />
<button className="model-button" type="button">
<Sparkles size={15} />
{runtime?.label ?? 'Runtime'}
<ChevronDown size={14} />
</button>
</div>
{isRunning ? (
<button
className="send-button send-button--stop"
type="button"
aria-label="停止生成"
onClick={() => void stop()}
>
<Square size={15} fill="currentColor" />
</button>
) : (
<button
className="send-button"
type="button"
aria-label="发送"
disabled={!input.trim()}
onClick={() => void submit()}
>
<Send size={17} />
</button>
)}
</div>
</div>
<p className="composer-hint">
AI
{appInfo?.shortcut && ` 快捷唤起:${appInfo.shortcut}`}
</p>
</footer>
</main>
</div>
)
}
export default App
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
import type { DesktopApi } from '../../shared/contracts'
declare global {
interface Window {
goodbuddy: DesktopApi
}
}
export {}
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles.css'
const root = document.getElementById('root')
if (!root) {
throw new Error('Root element not found')
}
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>
)
+768
View File
@@ -0,0 +1,768 @@
:root {
color: #1a2a23;
background: #f3f0e9;
font-family:
Inter, "SF Pro Display", "Segoe UI", "PingFang SC", "Microsoft YaHei",
sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
}
button,
input,
textarea {
color: inherit;
font: inherit;
}
button {
border: 0;
}
button:focus-visible,
input:focus-visible,
textarea:focus-visible {
outline: 2px solid #d8993f;
outline-offset: 2px;
}
.app-shell {
display: flex;
width: 100%;
height: 100%;
background:
radial-gradient(circle at 70% -20%, rgb(255 255 255 / 90%), transparent 36%),
#f7f5f0;
}
.sidebar {
display: flex;
flex: 0 0 278px;
flex-direction: column;
min-width: 0;
padding: 20px 14px 14px;
overflow: hidden;
background: #173a2c;
color: #f4f5ed;
transition:
flex-basis 180ms ease,
padding 180ms ease;
}
.sidebar--closed {
flex-basis: 0;
padding-right: 0;
padding-left: 0;
}
.brand {
display: flex;
align-items: center;
min-width: 248px;
padding: 0 8px 20px;
gap: 11px;
}
.brand__mark {
display: grid;
width: 38px;
height: 38px;
flex: 0 0 38px;
place-items: center;
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 12px;
background: #f1bb65;
color: #173a2c;
box-shadow: 0 7px 24px rgb(8 25 18 / 25%);
}
.brand__copy {
display: flex;
flex-direction: column;
gap: 2px;
}
.brand__copy strong {
font-size: 15px;
letter-spacing: 0.01em;
}
.brand__copy span {
color: #9bb4a8;
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.new-chat {
display: flex;
min-width: 248px;
align-items: center;
padding: 11px 12px;
border: 1px solid rgb(255 255 255 / 9%);
border-radius: 10px;
margin-bottom: 11px;
background: #29513f;
color: #fffdf7;
cursor: pointer;
gap: 9px;
text-align: left;
}
.new-chat:hover {
background: #315c49;
}
.new-chat span {
flex: 1;
font-size: 13px;
font-weight: 600;
}
.new-chat kbd {
padding: 2px 5px;
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 5px;
background: rgb(0 0 0 / 10%);
color: #a9c0b5;
font-size: 9px;
}
.sidebar-search {
display: flex;
min-width: 248px;
align-items: center;
padding: 9px 11px;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 9px;
margin-bottom: 15px;
background: rgb(8 29 20 / 25%);
color: #91aa9f;
gap: 8px;
}
.sidebar-search input {
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: #f5f6ef;
font-size: 12px;
}
.sidebar-search input::placeholder {
color: #8aa297;
}
.primary-nav {
display: flex;
min-width: 248px;
flex-direction: column;
padding-bottom: 13px;
border-bottom: 1px solid rgb(255 255 255 / 8%);
gap: 3px;
}
.nav-item {
display: flex;
align-items: center;
padding: 9px 10px;
border-radius: 8px;
background: transparent;
color: #adbbb4;
cursor: pointer;
gap: 10px;
text-align: left;
}
.nav-item:hover,
.nav-item--active {
background: rgb(255 255 255 / 7%);
color: #fffdf8;
}
.nav-item span:nth-child(2) {
flex: 1;
font-size: 12px;
font-weight: 550;
}
.nav-item__hint {
color: #6f8e7f;
font-size: 9px;
}
.conversation-list {
min-width: 248px;
flex: 1;
padding-top: 14px;
overflow: auto;
}
.section-label {
padding: 0 9px;
margin: 0 0 7px;
color: #779387;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.conversation-item {
display: flex;
width: 100%;
align-items: center;
padding: 9px 10px;
border-radius: 8px;
background: transparent;
color: #aabbb3;
cursor: pointer;
gap: 8px;
text-align: left;
}
.conversation-item:hover,
.conversation-item--active {
background: rgb(255 255 255 / 7%);
color: #fff;
}
.conversation-item span {
flex: 1;
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-item small {
color: #6f8c7e;
font-size: 9px;
}
.sidebar-footer {
min-width: 248px;
padding-top: 12px;
border-top: 1px solid rgb(255 255 255 / 8%);
}
.user-card {
display: flex;
width: 100%;
align-items: center;
padding: 7px 8px;
border-radius: 8px;
background: transparent;
color: #bdcbc4;
cursor: pointer;
gap: 9px;
text-align: left;
}
.user-card:hover {
background: rgb(255 255 255 / 6%);
}
.avatar {
display: grid;
width: 32px;
height: 32px;
place-items: center;
border-radius: 10px;
background: #e8b45f;
color: #173a2c;
font-size: 10px;
font-weight: 800;
}
.user-card__copy {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.user-card__copy strong {
color: #eff2eb;
font-size: 11px;
}
.user-card__copy small {
color: #789488;
font-size: 9px;
}
.workspace {
display: grid;
min-width: 0;
flex: 1;
grid-template-rows: 58px minmax(0, 1fr) auto;
}
.topbar {
display: flex;
align-items: center;
padding: 0 21px;
border-bottom: 1px solid #e4e0d7;
background: rgb(250 249 245 / 76%);
backdrop-filter: blur(16px);
}
.icon-button {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border-radius: 9px;
background: transparent;
color: #718078;
cursor: pointer;
}
.icon-button:hover {
background: #eeebe4;
color: #264537;
}
.conversation-title {
display: flex;
align-items: center;
padding: 7px 9px;
border-radius: 8px;
margin-left: 4px;
background: transparent;
color: #293b33;
cursor: pointer;
gap: 6px;
font-size: 12px;
font-weight: 650;
}
.conversation-title:hover {
background: #eeebe4;
}
.topbar__actions {
display: flex;
align-items: center;
margin-left: auto;
gap: 3px;
}
.runtime-status {
display: flex;
align-items: center;
padding: 6px 10px;
border: 1px solid #ddd8cc;
border-radius: 999px;
margin-right: 8px;
color: #796d5c;
font-size: 10px;
font-weight: 650;
gap: 6px;
}
.runtime-status__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #b79059;
box-shadow: 0 0 0 3px rgb(183 144 89 / 12%);
}
.runtime-status--online {
color: #315d49;
}
.runtime-status--online .runtime-status__dot {
background: #45a272;
box-shadow: 0 0 0 3px rgb(69 162 114 / 13%);
}
.chat {
min-height: 0;
padding: 28px max(28px, calc((100% - 820px) / 2)) 10px;
overflow-y: auto;
scrollbar-color: #d0cbc0 transparent;
scrollbar-width: thin;
}
.welcome {
padding: 26px 0 30px;
text-align: center;
}
.welcome__badge {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border: 1px solid #e0b46f;
border-radius: 14px;
margin: 0 auto 14px;
background: #f5c879;
color: #244536;
box-shadow: 0 10px 26px rgb(169 117 39 / 16%);
}
.eyebrow {
margin: 0 0 8px;
color: #a06f2e;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.16em;
}
.welcome h1 {
margin: 0;
color: #1d382c;
font-family: Georgia, "Songti SC", serif;
font-size: clamp(26px, 3vw, 36px);
font-weight: 500;
letter-spacing: -0.025em;
}
.welcome__description {
margin: 12px 0 22px;
color: #7c827d;
font-size: 12px;
}
.quick-actions {
display: grid;
max-width: 720px;
margin: 0 auto;
gap: 10px;
grid-template-columns: repeat(3, 1fr);
}
.quick-actions button {
display: grid;
min-height: 118px;
padding: 15px;
border: 1px solid #e3ded3;
border-radius: 13px;
background: rgb(255 255 255 / 56%);
cursor: pointer;
gap: 5px;
grid-template-rows: auto auto 1fr;
text-align: left;
transition:
transform 150ms ease,
border-color 150ms ease,
box-shadow 150ms ease;
}
.quick-actions button:hover {
border-color: #cfbc99;
box-shadow: 0 11px 30px rgb(54 44 28 / 7%);
transform: translateY(-2px);
}
.quick-actions__icon {
display: grid;
width: 31px;
height: 31px;
place-items: center;
border-radius: 9px;
margin-bottom: 4px;
background: #e9eee9;
color: #416b56;
}
.quick-actions strong {
color: #31423a;
font-size: 11px;
}
.quick-actions small {
color: #8a8e88;
font-size: 10px;
line-height: 1.45;
}
.message-list {
display: flex;
flex-direction: column;
padding-bottom: 20px;
gap: 4px;
}
.message {
display: grid;
padding: 17px 8px;
border-top: 1px solid transparent;
gap: 12px;
grid-template-columns: 30px 1fr;
}
.message + .message {
border-top-color: #ebe7df;
}
.message__avatar {
display: grid;
width: 29px;
height: 29px;
place-items: center;
border: 1px solid #d9d4ca;
border-radius: 9px;
background: #fffefa;
color: #416451;
}
.message--user .message__avatar {
border-color: #d5c29f;
background: #f1c97f;
color: #3a4c42;
}
.message__body {
min-width: 0;
}
.message__meta {
display: flex;
align-items: center;
margin: 1px 0 8px;
gap: 8px;
}
.message__meta strong {
color: #304239;
font-size: 11px;
}
.message__meta span {
color: #a0a29d;
font-size: 9px;
}
.message__content {
color: #3e4943;
font-size: 13px;
line-height: 1.75;
white-space: pre-wrap;
word-break: break-word;
}
.message__status {
display: flex;
align-items: center;
margin-top: 8px;
color: #858983;
font-size: 10px;
gap: 7px;
}
.message__status--error {
color: #a24d43;
}
.thinking-dot {
width: 6px;
height: 6px;
border-radius: 50%;
animation: pulse 1.1s ease-in-out infinite;
background: #d39b4f;
}
.tool-activity {
display: flex;
align-items: center;
padding: 9px 11px;
border: 1px solid #dedbd2;
border-radius: 9px;
margin-top: 8px;
background: #f1efe9;
color: #506158;
font-size: 10px;
gap: 8px;
}
.tool-activity span {
flex: 1;
}
.tool-activity small {
color: #8a8e88;
text-transform: uppercase;
}
.composer-wrap {
padding: 8px max(28px, calc((100% - 820px) / 2)) 15px;
background: linear-gradient(transparent, #f7f5f0 22%);
}
.composer {
padding: 12px 13px 10px;
border: 1px solid #d7d1c6;
border-radius: 15px;
background: #fffefa;
box-shadow:
0 12px 36px rgb(59 48 34 / 9%),
0 2px 5px rgb(59 48 34 / 4%);
}
.composer:focus-within {
border-color: #c9af82;
box-shadow:
0 12px 36px rgb(59 48 34 / 10%),
0 0 0 3px rgb(211 164 89 / 10%);
}
.composer textarea {
display: block;
width: 100%;
min-height: 38px;
max-height: 160px;
padding: 2px 3px;
border: 0;
outline: 0;
resize: none;
background: transparent;
color: #2f3d36;
font-size: 13px;
line-height: 1.55;
}
.composer textarea::placeholder {
color: #a2a099;
}
.composer__toolbar {
display: flex;
align-items: center;
min-height: 31px;
}
.composer__attachments {
display: flex;
align-items: center;
gap: 4px;
}
.composer__attachments button {
display: flex;
height: 29px;
align-items: center;
padding: 0 7px;
border-radius: 7px;
background: transparent;
color: #778079;
cursor: pointer;
gap: 5px;
}
.composer__attachments button:hover {
background: #f0eee8;
color: #345443;
}
.divider {
width: 1px;
height: 17px;
margin: 0 4px;
background: #e1ddd5;
}
.model-button {
font-size: 10px;
font-weight: 600;
}
.send-button {
display: grid;
width: 32px;
height: 32px;
place-items: center;
border-radius: 9px;
margin-left: auto;
background: #1d4a37;
color: #fff;
cursor: pointer;
box-shadow: 0 5px 12px rgb(29 74 55 / 18%);
}
.send-button:hover {
background: #276046;
}
.send-button:disabled {
background: #d7d4cc;
color: #9d9a93;
cursor: default;
box-shadow: none;
}
.send-button--stop {
background: #9a5147;
}
.composer-hint {
margin: 7px 0 0;
color: #9b9c97;
font-size: 9px;
text-align: center;
}
@keyframes pulse {
0%,
100% {
opacity: 0.35;
transform: scale(0.85);
}
50% {
opacity: 1;
transform: scale(1.1);
}
}
@media (max-width: 1020px) {
.sidebar {
flex-basis: 236px;
}
.brand,
.new-chat,
.sidebar-search,
.primary-nav,
.conversation-list,
.sidebar-footer {
min-width: 206px;
}
.quick-actions {
grid-template-columns: 1fr;
}
.quick-actions button {
min-height: 88px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
+4
View File
@@ -0,0 +1,4 @@
import '@testing-library/jest-dom/vitest'
import { vi } from 'vitest'
Element.prototype.scrollTo = vi.fn()