impl attachment inline chatbox

This commit is contained in:
2026-07-15 17:28:55 +08:00
parent f07717ee62
commit e524cc95bb
12 changed files with 425 additions and 89 deletions
+3
View File
@@ -14,7 +14,10 @@
"typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs"
},
"dependencies": {
"@lexical/react": "^0.47.0",
"@lexical/utils": "^0.47.0",
"@vitejs/plugin-react": "^5.0.4",
"lexical": "^0.47.0",
"lucide-react": "^0.546.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -1,5 +1,15 @@
import { create } from 'zustand';
export type RichText =
| {
type: 'text';
text: string;
}
| {
type: 'attachment';
attachmentId: string;
};
export type HomeAgentMode = 'game' | 'art' | 'doc';
export type HomeAttachmentDraft = {
@@ -13,17 +23,23 @@ export type HomeDraft = {
attachments: HomeAttachmentDraft[];
};
type UseHomeDraftStore = HomeDraft & {
type UseHomeDraftStore = {
mode: HomeAgentMode;
richText: RichText[];
attachments: HomeAttachmentDraft[];
setMode: (mode: HomeAgentMode) => void;
setPrompt: (prompt: string) => void;
setRichText: (richText: RichText[]) => void;
addAttachments: (attachments: HomeAttachmentDraft[]) => void;
removeAttachment: (attachmentId: string) => void;
reset: () => void;
};
const initialHomeDraft: HomeDraft = {
const initialHomeDraft: Pick<
UseHomeDraftStore,
'mode' | 'richText' | 'attachments'
> = {
mode: 'game',
prompt: '',
richText: [],
attachments: [],
};
@@ -31,14 +47,20 @@ const initialHomeDraft: HomeDraft = {
export const useLauncherHomeDraftStore = create<UseHomeDraftStore>((set) => ({
...initialHomeDraft,
setMode: (mode) => set({ mode }),
setPrompt: (prompt) => set({ prompt }),
setRichText: (richText) => set({ richText }),
addAttachments: (attachments) =>
set((state) => ({ attachments: [...state.attachments, ...attachments] })),
set((state) => ({
attachments: [...state.attachments, ...attachments],
})),
removeAttachment: (attachmentId) =>
set((state) => ({
attachments: state.attachments.filter(
(attachment) => attachment.id !== attachmentId,
),
richText: state.richText.filter(
(part) =>
part.type !== 'attachment' || part.attachmentId !== attachmentId,
),
})),
reset: () => set(initialHomeDraft),
}));
@@ -0,0 +1,39 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getNodeByKey, type NodeKey } from 'lexical';
import { X } from 'lucide-react';
import { useContext } from 'react';
import AttachmentContext from './attachmentContext';
export function AttachmentToken({
attachmentId,
nodeKey,
}: {
attachmentId: string;
nodeKey: NodeKey;
}) {
const [editor] = useLexicalComposerContext();
const { attachments, removeAttachment } = useContext(AttachmentContext);
const attachment = attachments.get(attachmentId);
if (!attachment) {
return null;
}
return (
<span className="mx-0.75 inline-flex min-h-6 max-w-full items-center gap-1 rounded-[7px] border border-(--platform-subpanel-border) bg-(--platform-neutral-bg) px-1.75 align-middle text-[11px] text-(--platform-neutral-text)">
{attachment.file.name}
<button
className="grid size-4.5 cursor-pointer place-items-center border-0 bg-transparent p-0 text-(--platform-text-soft)"
type="button"
aria-label={`移除附件 ${attachment.file.name}`}
onClick={() => {
editor.update(() => {
$getNodeByKey(nodeKey)?.remove();
});
removeAttachment(attachmentId);
}}
>
<X size={12} aria-hidden="true" />
</button>
</span>
);
}
@@ -0,0 +1,10 @@
import { createContext } from 'react';
import type { HomeAttachmentDraft } from '../../../../stores/useHomeDraftStore';
const AttachmentContext = createContext({
attachments: new Map<string, HomeAttachmentDraft>(),
removeAttachment: (_attachmentId: string): void => undefined,
});
export default AttachmentContext;
@@ -0,0 +1,81 @@
import {
$applyNodeReplacement,
DecoratorNode,
type EditorConfig,
type LexicalNode,
type NodeKey,
type SerializedLexicalNode,
type Spread,
} from 'lexical';
import type { ReactNode } from 'react';
import { AttachmentToken } from './attachment';
type SerializedAttachmentNode = Spread<
{
attachmentId: string;
type: 'home-attachment';
version: 1;
},
SerializedLexicalNode
>;
export class AttachmentNode extends DecoratorNode<ReactNode> {
__attachmentId: string;
static getType() {
return 'home-attachment';
}
static clone(node: AttachmentNode) {
return new AttachmentNode(node.__attachmentId, node.__key);
}
static importJSON(serializedNode: SerializedAttachmentNode) {
return $createAttachmentNode(serializedNode.attachmentId);
}
constructor(attachmentId: string, key?: NodeKey) {
super(key);
this.__attachmentId = attachmentId;
}
exportJSON(): SerializedAttachmentNode {
return {
...super.exportJSON(),
attachmentId: this.__attachmentId,
type: 'home-attachment',
version: 1,
};
}
createDOM(_config: EditorConfig) {
return document.createElement('span');
}
updateDOM() {
return false;
}
getTextContent() {
return '';
}
isInline() {
return true;
}
decorate() {
return <AttachmentToken attachmentId={this.__attachmentId} nodeKey={this.__key} />;
}
}
export function $createAttachmentNode(attachmentId: string) {
return $applyNodeReplacement(new AttachmentNode(attachmentId));
}
export function $isAttachmentNode(
node: LexicalNode | null | undefined,
): node is AttachmentNode {
return node instanceof AttachmentNode;
}
@@ -0,0 +1,204 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import {
$createParagraphNode,
$createTextNode,
$getRoot,
$getSelection,
$isElementNode,
$isRangeSelection,
$isTextNode,
COMMAND_PRIORITY_EDITOR,
createCommand,
type LexicalCommand,
type LexicalNode,
} from 'lexical';
import { Upload } from 'lucide-react';
import { useEffect, useMemo, useRef } from 'react';
import type {
HomeAttachmentDraft,
RichText,
} from '../../../../stores/useHomeDraftStore';
import AttachmentContext from './attachmentContext';
import {
$createAttachmentNode,
$isAttachmentNode,
AttachmentNode,
} from './attachmentNode';
type RichInputAreaProps = {
attachments: readonly HomeAttachmentDraft[];
value: readonly RichText[];
placeholder: string;
onChange: (value: RichText[]) => void;
onAttachmentsAdd: (attachments: HomeAttachmentDraft[]) => void;
onAttachmentRemove: (attachmentId: string) => void;
};
const INSERT_ATTACHMENTS_COMMAND: LexicalCommand<HomeAttachmentDraft[]> =
createCommand('INSERT_HOME_ATTACHMENTS_COMMAND');
function EditorPlugins({
// attachments,
onChange,
}: Pick<RichInputAreaProps, 'attachments' | 'onChange'>) {
const [editor] = useLexicalComposerContext();
useEffect(
() =>
editor.registerCommand(
INSERT_ATTACHMENTS_COMMAND,
(nextAttachments) => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
const root = $getRoot();
if (root.getChildrenSize() === 0)
root.append($createParagraphNode());
root.selectEnd();
}
const currentSelection = $getSelection();
if ($isRangeSelection(currentSelection)) {
currentSelection.insertNodes(
nextAttachments.map((attachment) =>
$createAttachmentNode(attachment.id),
),
);
}
return true;
},
COMMAND_PRIORITY_EDITOR,
),
[editor],
);
return (
<OnChangePlugin
onChange={(editorState) => {
editorState.read(() => {
const parts: RichText[] = [];
collectRichText($getRoot(), parts);
onChange(parts);
});
}}
/>
);
}
function collectRichText(node: LexicalNode, parts: RichText[]) {
if ($isTextNode(node)) {
parts.push({ type: 'text', text: node.getTextContent() });
return;
}
if ($isAttachmentNode(node)) {
parts.push({ type: 'attachment', attachmentId: node.__attachmentId });
return;
}
if ($isElementNode(node)) {
node.getChildren().forEach((child) => collectRichText(child, parts));
}
}
function UploadButton({
onAttachmentsAdd,
}: Pick<RichInputAreaProps, 'onAttachmentsAdd'>) {
const [editor] = useLexicalComposerContext();
const inputRef = useRef<HTMLInputElement | null>(null);
return (
<>
<input
ref={inputRef}
className="launcher-file-input pointer-events-none absolute size-px opacity-0"
type="file"
multiple
onChange={(event) => {
const files = Array.from(event.currentTarget.files ?? []);
event.currentTarget.value = '';
if (files.length === 0) return;
const now = Date.now();
const nextAttachments = files.map((file, index) => ({
id: `${file.name}:${file.size}:${file.lastModified}:${index}:${now}`,
file,
}));
editor.dispatchCommand(INSERT_ATTACHMENTS_COMMAND, nextAttachments);
onAttachmentsAdd(nextAttachments);
}}
/>
<button
className="grid size-6 cursor-pointer place-items-center rounded-full border-0 bg-transparent p-0 text-(--platform-icon-text)"
type="button"
aria-label="上传素材"
onClick={() => inputRef.current?.click()}
>
<Upload size={15} aria-hidden="true" />
</button>
</>
);
}
export default function RichInputArea(props: RichInputAreaProps) {
const attachmentMap = useMemo(
() =>
new Map(
props.attachments.map((attachment) => [attachment.id, attachment]),
),
[props.attachments],
);
return (
<AttachmentContext.Provider
value={{
attachments: attachmentMap,
removeAttachment: props.onAttachmentRemove,
}}
>
<LexicalComposer
initialConfig={{
namespace: 'home-rich-input',
nodes: [AttachmentNode],
editorState: () => {
const paragraph = $createParagraphNode();
for (const part of props.value) {
paragraph.append(
part.type === 'text'
? $createTextNode(part.text)
: $createAttachmentNode(part.attachmentId),
);
}
$getRoot().append(paragraph);
},
onError: (error) => {
throw error;
},
}}
>
<div className="grid min-h-9 gap-2">
<RichTextPlugin
contentEditable={
<ContentEditable
aria-label="创作想法"
className="min-h-9 w-full whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 text-[13px] text-(--platform-text-strong) outline-0"
/>
}
placeholder={
<span className="pointer-events-none absolute text-[13px] text-(--platform-text-muted)">
{props.placeholder}
</span>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<div className="flex items-center">
<UploadButton onAttachmentsAdd={props.onAttachmentsAdd} />
</div>
</div>
<EditorPlugins
attachments={props.attachments}
onChange={props.onChange}
/>
</LexicalComposer>
</AttachmentContext.Provider>
);
}
@@ -0,0 +1,31 @@
import type {
HomeAttachmentDraft,
RichText,
} from '../../../../stores/useHomeDraftStore';
function ordinalSuffix(index: number) {
if (index % 100 >= 11 && index % 100 <= 13) return 'th';
return (
({ 1: 'st', 2: 'nd', 3: 'rd' } as Record<number, string>)[index % 10] ??
'th'
);
}
export function richTextToPrompt(
parts: readonly RichText[],
attachments: readonly HomeAttachmentDraft[],
) {
const attachmentById = new Map(
attachments.map((attachment) => [attachment.id, attachment]),
);
let attachmentIndex = 0;
return parts
.map((part) => {
if (part.type === 'text') return part.text;
const attachment = attachmentById.get(part.attachmentId);
if (!attachment) return '';
attachmentIndex += 1;
return `{${attachmentIndex}${ordinalSuffix(attachmentIndex)} attachment: ${attachment.file.name}}`;
})
.join('')
.trim();
}
@@ -3,19 +3,19 @@ import {
type LucideIcon,
Plus,
Sparkles,
Upload,
X,
} from 'lucide-react';
import type {
FormEvent,
} from 'react';
import { useRef, useState } from 'react';
import { useState } from 'react';
import {
type HomeAgentMode,
type HomeDraft,
useLauncherHomeDraftStore,
} from '../../stores/useHomeDraftStore';
import RichInputArea from './components/RichInputArea';
import { richTextToPrompt } from './components/RichInputArea/richTextToPrompt';
import { useHomeShowcase } from './useHomeShowcase';
export type {
@@ -71,12 +71,14 @@ export default function HomeView({
onProjectOpen,
}: HomeViewProps) {
const homeAgentMode = useLauncherHomeDraftStore((state) => state.mode);
const homePrompt = useLauncherHomeDraftStore((state) => state.prompt);
const homeRichText = useLauncherHomeDraftStore((state) => state.richText);
const homeAttachments = useLauncherHomeDraftStore(
(state) => state.attachments,
);
const setHomeAgentMode = useLauncherHomeDraftStore((state) => state.setMode);
const setHomePrompt = useLauncherHomeDraftStore((state) => state.setPrompt);
const setHomeRichText = useLauncherHomeDraftStore(
(state) => state.setRichText,
);
const addHomeAttachments = useLauncherHomeDraftStore(
(state) => state.addAttachments,
);
@@ -86,7 +88,6 @@ export default function HomeView({
const [homeCreationBusy, setHomeCreationBusy] = useState(false);
const { resources: showcaseResources, status: showcaseStatus } =
useHomeShowcase();
const homeAttachmentInputRef = useRef<HTMLInputElement | null>(null);
const activeHomeMode =
homeAgentModeItems.find((item) => item.mode === homeAgentMode) ??
homeAgentModeItems[0];
@@ -97,25 +98,10 @@ export default function HomeView({
const ActiveHomeModeIcon = activeHomeMode.icon;
function handleHomeAttachmentSelection(
event: React.ChangeEvent<HTMLInputElement>,
) {
const files = Array.from(event.currentTarget.files ?? []);
event.currentTarget.value = '';
if (files.length === 0) {
return;
}
addHomeAttachments(
files.map((file, index) => ({
id: `${file.name}:${file.size}:${file.lastModified}:${index}:${Date.now()}`,
file,
})),
);
}
async function handleHomeSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!homePrompt.trim() && homeAttachments.length === 0) {
const prompt = richTextToPrompt(homeRichText, homeAttachments);
if (!prompt) {
onStatusChange(
homeAgentModeItems.find((item) => item.mode === homeAgentMode)
?.emptyPrompt ?? '请输入需求或上传附件',
@@ -128,7 +114,7 @@ export default function HomeView({
onStatusChange(
await onCreateDraft({
mode: homeAgentMode,
prompt: homePrompt,
prompt,
attachments: homeAttachments,
}),
);
@@ -185,55 +171,19 @@ export default function HomeView({
})}
</div>
<form
className="grid min-h-20 w-[min(488px,calc(100vw-110px))] grid-rows-[minmax(42px,auto)_auto_auto] rounded-[18px] border border-(--platform-surface-border) bg-(--platform-input-fill) px-3 pb-2 pt-3.25 shadow-(--platform-panel-shadow) focus-within:bg-(--platform-input-fill-focus) focus-within:ring-4 focus-within:ring-(--platform-input-focus-ring) max-[760px]:w-[min(100%,calc(100vw-76px))]"
className="grid min-h-20 w-[min(488px,calc(100vw-110px))] grid-rows-[minmax(42px,auto)_auto] rounded-[18px] border border-(--platform-surface-border) bg-(--platform-input-fill) px-3 pb-2 pt-3.25 shadow-(--platform-panel-shadow) focus-within:bg-(--platform-input-fill-focus) focus-within:ring-4 focus-within:ring-(--platform-input-focus-ring) max-[760px]:w-[min(100%,calc(100vw-76px))]"
onSubmit={handleHomeSubmit}
>
<textarea
className="min-h-9 w-full resize-none border-0 bg-transparent p-0 text-[13px] text-(--platform-text-strong) outline-0 placeholder:text-(--platform-text-muted)"
aria-label="创作想法"
<RichInputArea
attachments={homeAttachments}
value={homeRichText}
placeholder={activeHomeMode.placeholder}
value={homePrompt}
onChange={(event) => setHomePrompt(event.currentTarget.value)}
onChange={setHomeRichText}
onAttachmentsAdd={addHomeAttachments}
onAttachmentRemove={removeHomeAttachment}
/>
{homeAttachments.length > 0 ? (
<div
className="flex flex-wrap gap-1.5 py-2 pb-1"
aria-label="附件队列"
>
{homeAttachments.map((attachment) => (
<span
className="inline-flex min-h-6 max-w-full items-center gap-1 rounded-[7px] border border-(--platform-subpanel-border) bg-(--platform-neutral-bg) px-1.75 text-[11px] text-(--platform-neutral-text)"
key={attachment.id}
>
{attachment.file.name}
<button
className="size-4.5 cursor-pointer border-0 bg-transparent p-0 text-(--platform-text-soft)"
type="button"
aria-label={`移除附件 ${attachment.file.name}`}
onClick={() => removeHomeAttachment(attachment.id)}
>
<X size={12} aria-hidden="true" />
</button>
</span>
))}
</div>
) : null}
<div className="grid grid-cols-[auto_1fr_auto] items-center gap-2.5 text-[12px] text-(--platform-text-soft)">
<input
ref={homeAttachmentInputRef}
className="launcher-file-input pointer-events-none absolute size-px opacity-0"
type="file"
multiple
onChange={handleHomeAttachmentSelection}
/>
<button
className="grid size-6 cursor-pointer place-items-center rounded-full border-0 bg-transparent p-0 text-(--platform-icon-text)"
type="button"
aria-label="上传素材"
onClick={() => homeAttachmentInputRef.current?.click()}
>
<Upload size={15} aria-hidden="true" />
</button>
<div className="grid grid-cols-[1fr_auto] items-center gap-2.5 text-[12px] text-(--platform-text-soft)">
<span>{status}</span>
<button
className="grid size-6 cursor-pointer place-items-center rounded-full border-0 bg-(image:--platform-button-primary-fill) p-0 text-(--platform-button-primary-text) shadow-(--platform-profile-action-shadow) disabled:cursor-not-allowed disabled:opacity-55"
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import {
listClientShowcaseResources,
type EditorShowcaseResource,
listClientShowcaseResources,
} from '../../services/clientApi';
export function useHomeShowcase() {
@@ -1,4 +1,7 @@
/** @vitest-environment jsdom */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
act,
cleanup,
@@ -8,23 +11,21 @@ import {
waitFor,
within,
} from '@testing-library/react';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
import {
createGameCreationAppManifest,
createGameCreationAppSeedTasks,
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
type GameCreationAgentRunTrace,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
import {
App,
AuthenticatedClient,
WorkspaceLauncher,
deriveAgentStatusCards,
WorkspaceLauncher,
} from '../src/App';
const testAuthUser: AuthUser = {
@@ -93,7 +94,7 @@ function mockRoleAgentReply() {
afterEach(() => {
cleanup();
window.history.pushState({}, '', '/');
window.localStorage.clear();
window.localStorage?.clear();
delete window.__TAURI__;
vi.restoreAllMocks();
});
@@ -1583,9 +1584,6 @@ describe('AI 游戏创作 App 界面边界', () => {
renderLauncherAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '做素材' }));
fireEvent.change(screen.getByLabelText('创作想法'), {
target: { value: '做一张像素风厨师角色图' },
});
const fileInput = document.querySelector(
'.launcher-file-input',
) as HTMLInputElement;
@@ -1600,8 +1598,7 @@ describe('AI 游戏创作 App 界面边界', () => {
target: { files: [file] },
});
expect(screen.getByLabelText('附件队列')).not.toBeNull();
expect(screen.getByText('reference.png')).not.toBeNull();
expect(await screen.findByText('reference.png')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '开启创作' }));
@@ -1609,7 +1606,6 @@ describe('AI 游戏创作 App 界面边界', () => {
expect(screen.getByText('home-created-game')).not.toBeNull();
expect(screen.getByText('/tmp/home-created-game')).not.toBeNull();
expect(screen.getByText('做素材')).not.toBeNull();
expect(screen.getByText('做一张像素风厨师角色图')).not.toBeNull();
expect(screen.getByText('assets/uploads/reference.png')).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('init_local_game_project', {
projectPath: '/tmp/home-created-game',
@@ -1637,7 +1633,7 @@ describe('AI 游戏创作 App 界面边界', () => {
});
expect(conversationCalls[0]?.[1]).toMatchObject({
message: {
content: expect.stringContaining('做一张像素风厨师角色图'),
content: expect.stringContaining('{1st attachment: reference.png}'),
},
});
expect(conversationCalls[0]?.[1]).toMatchObject({
@@ -159,7 +159,7 @@ game-project/
- 短期记忆、长期记忆、项目黑板和角色私有记忆按授权本地项目路径读写;普通用户仍只通过聊天命令访问短期 / 长期 / 黑板记忆,角色私有记忆只在单 agent 对话和生成 loop 中按目标 agent 读取。
- 结构化对话记录按授权本地项目路径追加 JSONL;普通聊天、`/history`、工作区历史和单 agent 对话都读取 `.agent/conversations/`,最近 project / agent 对话可进入生成 prompt 上下文,但 v1 不提供 fork、archive 或云端同步。
- Agent 状态列表从 `.agent/manifest.json` 的任务 / 角色清单和 `.agent/run.latest.json` / `.agent/runs/<runId>.json` 的 step、taskGraph、passPlans、lifecycleStatus 派生;v1 不新增独立状态数据库,也不承诺完整后台 runner。
- App 启动先检查平台登录态;登录后进入同一个客户端首页,不再有面向用户的启动器 / 主窗口切换概念。首页按 `做游戏` / `做素材` / `做方案` 保存 `game` / `art` / `doc` 初始意图,发送时弹出原生目录选择,目标目录存在且非空时必须二次确认;确认后只调用 `init_local_game_project` 初始化本地项目、`upload_local_asset` 导入附件、`append_local_conversation_message` 记录首条需求和接收回执,再写入最近项目并切到项目开发占位页,成功后清空首页草稿。取消和创建失败的状态必须回显到首页;首页响应式断点与应用外壳统一为 `760px`。本流程不调用 `generate_local_game_draft``generate_platform_art_asset` 或 LLM 聊天。
- App 启动先检查平台登录态;登录后进入同一个客户端首页,不再有面向用户的启动器 / 主窗口切换概念。首页按 `做游戏` / `做素材` / `做方案` 保存 `game` / `art` / `doc` 初始意图,输入状态按文字与附件 token 的顺序保存,附件以文件名 token 内嵌在输入框中而非堆叠在下方;提交时才将 token 转为 LLM 可读的 `{1st attachment}` 引用。发送时弹出原生目录选择,目标目录存在且非空时必须二次确认;确认后只调用 `init_local_game_project` 初始化本地项目、`upload_local_asset` 导入附件、`append_local_conversation_message` 记录首条需求和接收回执,再写入最近项目并切到项目开发占位页,成功后清空首页草稿。取消和创建失败的状态必须回显到首页;首页响应式断点与应用外壳统一为 `760px`。本流程不调用 `generate_local_game_draft``generate_platform_art_asset` 或 LLM 聊天。
- debug 构建启动后在用户 `client` 窗口之外额外打开 `developer` 窗口;该窗口用于开发者单独选择 Agent、切换时自动读取该 Agent 历史,并把用户消息和真实 Agent 回复持久化到 `.agent/conversations/agents/<agentId>.jsonl`,普通用户窗口不得出现 `Agent 聊天` 导航或入口。
- 首页最近项目只展示最近 3 个有效项目;项目组页在同一窗口管理最近项目、打开项目、新建项目和显示目录。打开项目只读取已初始化项目并切到项目开发占位页,不打开第二窗口;新建项目仍沿用非空目录确认,不自动重建无效历史路径。
- 项目开发占位页保留左侧栏和顶部栏,展示项目名、路径、创建模式、首条需求、附件导入结果、最近 run 状态和后续“项目开发画布”占位;本轮不落地真正画板 + Agent 双栏。
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB