diff --git a/CHANGELOG.md b/CHANGELOG.md
index bdee180..509262a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
### 改进
- Claude 默认设置为 1M 上下文(opus / sonnet 自动使用 `[1m]` 模型,haiku 保持不变)
+- 新会话弹窗补充工作目录默认提示、最近目录快捷项和目录选择器
## v1.2.10
diff --git a/README.md b/README.md
index 6f36a0a..e641ee4 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,7 @@ https://github.com/ZgDaniel/cc-web 给我装!
- **超轻量** — 后端性能占用少,前端通过 web 访问
- **双 Agent 会话** — 新建会话时可选择 Claude 或 Codex,沿用相同的 Web 会话与后台任务模型
+- **工作目录更顺手** — 新会话弹窗会显示默认目录、最近目录快捷项,也能直接弹出目录选择器
- **Agent 视图隔离** — 侧边栏切换 Claude / Codex 后,仅展示当前 Agent 的会话与最近记录,互不干扰
- **独立 Agent 设置** — Claude 与 Codex 拥有各自的设置入口与默认行为,保持贴近各自原生 CLI 的使用方式
- **多会话管理** — 创建、切换、重命名、删除会话,删除时同步清除本地 Claude 历史记录
diff --git a/lib/agent-runtime.js b/lib/agent-runtime.js
index ca759b6..01490fb 100644
--- a/lib/agent-runtime.js
+++ b/lib/agent-runtime.js
@@ -339,8 +339,21 @@ function createAgentRuntime(deps) {
if (!item || !item.id) break;
if (item.type === 'agent_message') {
if (item.text) {
- entry.fullText += item.text;
- wsSend(entry.ws, { type: 'text_delta', text: item.text });
+ let parsedContent = null;
+ try {
+ parsedContent = JSON.parse(item.text);
+ } catch {}
+
+ if (parsedContent && Array.isArray(parsedContent)) {
+ if (!entry.contentBlocks) entry.contentBlocks = [];
+ entry.contentBlocks.push(...parsedContent);
+ const textOnly = parsedContent.filter(b => b.type === 'text').map(b => b.text || '').join('');
+ entry.fullText += textOnly;
+ wsSend(entry.ws, { type: 'content_blocks', blocks: parsedContent });
+ } else {
+ entry.fullText += item.text;
+ wsSend(entry.ws, { type: 'text_delta', text: item.text });
+ }
}
break;
}
diff --git a/package-lock.json b/package-lock.json
index 2593773..44f85a1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "cc-web",
- "version": "1.2.8",
+ "version": "1.2.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cc-web",
- "version": "1.2.8",
+ "version": "1.2.11",
"dependencies": {
"ws": "^8.18.0"
}
diff --git a/public/app.js b/public/app.js
index 7875c68..8c48d86 100644
--- a/public/app.js
+++ b/public/app.js
@@ -100,6 +100,8 @@
let loginPasswordValue = ''; // store login password for force-change flow
let currentCwd = null;
let currentSessionRunning = false;
+ let fileBrowserState = null;
+ let directoryPickerState = null;
let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
let pendingInitialSessionLoad = false;
@@ -552,6 +554,495 @@
return `${(size / (1024 * 1024)).toFixed(1)}MB`;
}
+ function normalizeBrowserPath(input) {
+ return String(input || '')
+ .replace(/\\/g, '/')
+ .split('/')
+ .filter((part) => part && part !== '.')
+ .join('/');
+ }
+
+ function getPathLeaf(input) {
+ const normalized = String(input || '').replace(/\\/g, '/').replace(/\/+$/, '');
+ if (!normalized) return '';
+ const parts = normalized.split('/');
+ return parts[parts.length - 1] || normalized;
+ }
+
+ function getBrowserParentPath(currentPath) {
+ const normalized = normalizeBrowserPath(currentPath);
+ if (!normalized) return '';
+ const parts = normalized.split('/');
+ parts.pop();
+ return parts.join('/');
+ }
+
+ function getBrowserDisplayPath(rootPath, currentPath) {
+ const root = String(rootPath || '').replace(/\\/g, '/').replace(/\/+$/, '');
+ const current = normalizeBrowserPath(currentPath);
+ return current ? `${root}/${current}` : root;
+ }
+
+ async function fetchAuthJson(url, options = {}) {
+ await ensureAuthenticatedWs();
+ const response = await fetch(url, {
+ ...options,
+ headers: {
+ ...(options.headers || {}),
+ Authorization: `Bearer ${authToken}`,
+ },
+ });
+ const rawText = await response.text();
+ let data = null;
+ try {
+ data = rawText ? JSON.parse(rawText) : null;
+ } catch {
+ data = null;
+ }
+ if (response.status === 401) {
+ throw new Error('登录状态已失效,请刷新页面后重新登录。');
+ }
+ if (!response.ok || data?.ok === false) {
+ throw new Error(data?.message || `请求失败 (${response.status})`);
+ }
+ return data || {};
+ }
+
+ function closeDirectoryPicker() {
+ if (!directoryPickerState) return;
+ const { overlay, escapeHandler } = directoryPickerState;
+ if (escapeHandler) document.removeEventListener('keydown', escapeHandler);
+ if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
+ directoryPickerState = null;
+ }
+
+ function setDirectoryPickerStatus(message, type = '') {
+ if (!directoryPickerState?.statusEl) return;
+ directoryPickerState.statusEl.textContent = message || '';
+ directoryPickerState.statusEl.dataset.state = type || '';
+ }
+
+ function updateDirectoryPickerPathBar() {
+ if (!directoryPickerState?.pathEl) return;
+ const displayPath = directoryPickerState.currentPath || directoryPickerState.defaultPath || '';
+ directoryPickerState.pathEl.textContent = displayPath;
+ directoryPickerState.pathEl.title = displayPath;
+ directoryPickerState.upBtn.disabled = !directoryPickerState.parentPath;
+ directoryPickerState.chooseBtn.disabled = !displayPath;
+ }
+
+ function renderDirectoryPickerEntries(entries) {
+ if (!directoryPickerState?.listEl) return;
+ const safeEntries = Array.isArray(entries) ? entries : [];
+ if (safeEntries.length === 0) {
+ directoryPickerState.listEl.innerHTML = '
这个目录里没有可进入的子目录,直接使用当前目录也可以。
';
+ return;
+ }
+
+ directoryPickerState.listEl.innerHTML = safeEntries.map((entry) => {
+ const metaParts = [entry.symlink ? '链接目录' : '目录'];
+ if (entry.updatedAt) metaParts.push(timeAgo(entry.updatedAt));
+ return `
+
+ `;
+ }).join('');
+
+ directoryPickerState.listEl.querySelectorAll('.file-browser-item').forEach((button) => {
+ button.addEventListener('click', () => {
+ const targetPath = button.dataset.path || '';
+ if (targetPath) loadDirectoryPickerDirectory(targetPath);
+ });
+ });
+ }
+
+ async function loadDirectoryPickerDirectory(targetPath, options = {}) {
+ if (!directoryPickerState) return;
+ const state = directoryPickerState;
+ const requestId = ++state.requestId;
+ state.listEl.innerHTML = '正在读取目录…
';
+ setDirectoryPickerStatus('正在读取目录…');
+
+ try {
+ const data = await fetchAuthJson(`/api/fs/directories?path=${encodeURIComponent(targetPath || '')}`);
+ if (!directoryPickerState || state !== directoryPickerState || requestId !== state.requestId) return;
+ state.currentPath = data.currentPath || state.currentPath;
+ state.parentPath = data.parentPath || '';
+ state.defaultPath = data.defaultPath || state.defaultPath;
+ updateDirectoryPickerPathBar();
+ renderDirectoryPickerEntries(data.entries || []);
+ const statusParts = [];
+ if (data.truncated) statusParts.push(`目录较大,仅显示前 ${data.entryLimit} 项`);
+ statusParts.push(`当前共 ${Array.isArray(data.entries) ? data.entries.length : 0} 个子目录`);
+ setDirectoryPickerStatus(statusParts.join(' · '));
+ } catch (err) {
+ if (!directoryPickerState || state !== directoryPickerState || requestId !== state.requestId) return;
+ if (options.allowFallback !== false && targetPath) {
+ loadDirectoryPickerDirectory('', { allowFallback: false });
+ return;
+ }
+ state.listEl.innerHTML = `${escapeHtml(err.message || '目录读取失败')}
`;
+ setDirectoryPickerStatus(err.message || '目录读取失败', 'error');
+ }
+ }
+
+ function showDirectoryPicker(options = {}) {
+ closeDirectoryPicker();
+
+ const overlay = document.createElement('div');
+ overlay.className = 'modal-overlay';
+ overlay.id = 'directory-picker-overlay';
+ overlay.innerHTML = `
+
+ `;
+
+ document.body.appendChild(overlay);
+
+ directoryPickerState = {
+ overlay,
+ pathEl: overlay.querySelector('[data-picker-path]'),
+ statusEl: overlay.querySelector('[data-picker-status]'),
+ listEl: overlay.querySelector('[data-picker-list]'),
+ upBtn: overlay.querySelector('[data-picker-up]'),
+ refreshBtn: overlay.querySelector('[data-picker-refresh]'),
+ chooseBtn: overlay.querySelector('[data-picker-choose]'),
+ currentPath: '',
+ parentPath: '',
+ defaultPath: '',
+ requestId: 0,
+ onChoose: typeof options.onChoose === 'function' ? options.onChoose : null,
+ escapeHandler: null,
+ };
+
+ directoryPickerState.escapeHandler = (e) => {
+ if (e.key === 'Escape') closeDirectoryPicker();
+ };
+ document.addEventListener('keydown', directoryPickerState.escapeHandler);
+
+ const closeButtons = overlay.querySelectorAll('[data-picker-close], [data-picker-cancel]');
+ closeButtons.forEach((button) => button.addEventListener('click', closeDirectoryPicker));
+ overlay.addEventListener('click', (e) => {
+ if (e.target === overlay) closeDirectoryPicker();
+ });
+ directoryPickerState.upBtn.addEventListener('click', () => {
+ if (directoryPickerState?.parentPath) loadDirectoryPickerDirectory(directoryPickerState.parentPath, { allowFallback: false });
+ });
+ directoryPickerState.refreshBtn.addEventListener('click', () => {
+ loadDirectoryPickerDirectory(directoryPickerState?.currentPath || '', { allowFallback: false });
+ });
+ directoryPickerState.chooseBtn.addEventListener('click', () => {
+ const selectedPath = directoryPickerState?.currentPath || directoryPickerState?.defaultPath || '';
+ const onChoose = directoryPickerState?.onChoose;
+ closeDirectoryPicker();
+ if (selectedPath && typeof onChoose === 'function') onChoose(selectedPath);
+ });
+
+ updateDirectoryPickerPathBar();
+ loadDirectoryPickerDirectory(String(options.initialPath || '').trim(), { allowFallback: true });
+ }
+
+ function closeFileBrowser() {
+ if (!fileBrowserState) return;
+ const { overlay, escapeHandler } = fileBrowserState;
+ if (escapeHandler) document.removeEventListener('keydown', escapeHandler);
+ if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
+ fileBrowserState = null;
+ }
+
+ function setFileBrowserStatus(message, type = '') {
+ if (!fileBrowserState?.statusEl) return;
+ fileBrowserState.statusEl.textContent = message || '';
+ fileBrowserState.statusEl.dataset.state = type || '';
+ }
+
+ function setFileBrowserPreviewMode(active) {
+ if (!fileBrowserState?.panel) return;
+ fileBrowserState.panel.classList.toggle('preview-active', !!active);
+ }
+
+ function syncFileBrowserSelection() {
+ if (!fileBrowserState?.listEl) return;
+ fileBrowserState.listEl.querySelectorAll('.file-browser-item').forEach((button) => {
+ button.classList.toggle(
+ 'active',
+ button.dataset.kind === 'file' && button.dataset.path === fileBrowserState.selectedFilePath
+ );
+ });
+ }
+
+ function renderFileBrowserPreviewEmpty(title, message) {
+ if (!fileBrowserState) return;
+ fileBrowserState.previewTitleEl.textContent = title || '文件预览';
+ fileBrowserState.previewMetaEl.textContent = message || '选择一个文本文件查看内容';
+ fileBrowserState.previewEmptyEl.textContent = message || '选择一个文本文件查看内容';
+ fileBrowserState.previewEmptyEl.hidden = false;
+ fileBrowserState.previewCodeEl.hidden = true;
+ fileBrowserState.previewCodeEl.textContent = '';
+ }
+
+ function renderFileBrowserPreviewLoading(name) {
+ if (!fileBrowserState) return;
+ fileBrowserState.previewTitleEl.textContent = name || '文件预览';
+ fileBrowserState.previewMetaEl.textContent = '正在读取文件内容…';
+ fileBrowserState.previewEmptyEl.textContent = '正在读取文件内容…';
+ fileBrowserState.previewEmptyEl.hidden = false;
+ fileBrowserState.previewCodeEl.hidden = true;
+ fileBrowserState.previewCodeEl.textContent = '';
+ }
+
+ function updateFileBrowserPathBar() {
+ if (!fileBrowserState) return;
+ const displayPath = getBrowserDisplayPath(fileBrowserState.rootPath, fileBrowserState.currentPath);
+ fileBrowserState.pathEl.textContent = displayPath;
+ fileBrowserState.pathEl.title = displayPath;
+ fileBrowserState.upBtn.disabled = !fileBrowserState.currentPath;
+ }
+
+ function renderFileBrowserDirectory(entries) {
+ if (!fileBrowserState) return;
+ const state = fileBrowserState;
+ const safeEntries = Array.isArray(entries) ? entries : [];
+
+ if (safeEntries.length === 0) {
+ state.listEl.innerHTML = '这个目录里还没有可显示的文件
';
+ return;
+ }
+
+ state.listEl.innerHTML = safeEntries.map((entry) => {
+ const metaParts = [];
+ if (entry.kind === 'directory') {
+ metaParts.push(entry.symlink ? '链接目录' : '目录');
+ } else {
+ metaParts.push(entry.previewableHint ? '文本' : '文件');
+ if (entry.size >= 0) metaParts.push(formatFileSize(entry.size));
+ }
+ if (entry.updatedAt) metaParts.push(timeAgo(entry.updatedAt));
+ return `
+
+ `;
+ }).join('');
+
+ state.listEl.querySelectorAll('.file-browser-item').forEach((button) => {
+ button.addEventListener('click', () => {
+ const itemPath = normalizeBrowserPath(button.dataset.path || '');
+ if (button.dataset.kind === 'directory') {
+ loadFileBrowserDirectory(itemPath);
+ return;
+ }
+ openFileBrowserFile(itemPath);
+ });
+ });
+
+ syncFileBrowserSelection();
+ }
+
+ async function loadFileBrowserDirectory(targetPath, options = {}) {
+ if (!fileBrowserState) return;
+ const state = fileBrowserState;
+ const normalizedPath = normalizeBrowserPath(targetPath);
+ const previousPath = state.currentPath;
+ const requestId = ++state.directoryRequestId;
+ state.currentPath = normalizedPath;
+ updateFileBrowserPathBar();
+ state.listEl.innerHTML = '正在读取目录…
';
+ setFileBrowserStatus('正在读取目录…');
+ if (!options.preservePreview) {
+ state.selectedFilePath = '';
+ syncFileBrowserSelection();
+ renderFileBrowserPreviewEmpty('文件预览', '选择一个文本文件查看内容');
+ setFileBrowserPreviewMode(false);
+ }
+
+ try {
+ const data = await fetchAuthJson(`/api/fs/list?sessionId=${encodeURIComponent(state.sessionId)}&path=${encodeURIComponent(normalizedPath)}`);
+ if (!fileBrowserState || state !== fileBrowserState || requestId !== state.directoryRequestId) return;
+ state.rootPath = data.rootPath || state.rootPath;
+ state.currentPath = normalizeBrowserPath(data.currentPath || '');
+ updateFileBrowserPathBar();
+ renderFileBrowserDirectory(data.entries || []);
+ const statusParts = [];
+ if (data.truncated) statusParts.push(`目录较大,仅显示前 ${data.entryLimit} 项`);
+ statusParts.push(`当前共 ${Array.isArray(data.entries) ? data.entries.length : 0} 项`);
+ setFileBrowserStatus(statusParts.join(' · '));
+ } catch (err) {
+ if (!fileBrowserState || state !== fileBrowserState || requestId !== state.directoryRequestId) return;
+ state.currentPath = previousPath;
+ updateFileBrowserPathBar();
+ state.listEl.innerHTML = `${escapeHtml(err.message || '目录读取失败')}
`;
+ setFileBrowserStatus(err.message || '目录读取失败', 'error');
+ }
+ }
+
+ async function openFileBrowserFile(targetPath) {
+ if (!fileBrowserState) return;
+ const state = fileBrowserState;
+ const normalizedPath = normalizeBrowserPath(targetPath);
+ const requestId = ++state.previewRequestId;
+ state.selectedFilePath = normalizedPath;
+ syncFileBrowserSelection();
+ renderFileBrowserPreviewLoading(normalizedPath.split('/').pop() || '文件预览');
+ setFileBrowserPreviewMode(true);
+
+ try {
+ const data = await fetchAuthJson(`/api/fs/read?sessionId=${encodeURIComponent(state.sessionId)}&path=${encodeURIComponent(normalizedPath)}`);
+ if (!fileBrowserState || state !== fileBrowserState || requestId !== state.previewRequestId) return;
+ state.selectedFilePath = normalizeBrowserPath(data.path || normalizedPath);
+ syncFileBrowserSelection();
+ state.previewTitleEl.textContent = data.name || '文件预览';
+ const metaParts = [formatFileSize(data.size || 0)];
+ if (data.updatedAt) metaParts.push(timeAgo(data.updatedAt));
+ if (data.truncated) metaParts.push(`仅显示前 ${formatFileSize(data.previewBytes || 0)}`);
+ state.previewMetaEl.textContent = metaParts.join(' · ');
+ state.previewEmptyEl.hidden = true;
+ state.previewCodeEl.hidden = false;
+ state.previewCodeEl.textContent = data.content || '';
+ setFileBrowserStatus(`已打开 ${data.name || '文件'}`);
+ } catch (err) {
+ if (!fileBrowserState || state !== fileBrowserState || requestId !== state.previewRequestId) return;
+ state.selectedFilePath = '';
+ syncFileBrowserSelection();
+ renderFileBrowserPreviewEmpty('无法预览', err.message || '当前文件无法打开');
+ setFileBrowserStatus(err.message || '当前文件无法打开', 'error');
+ }
+ }
+
+ function showFileBrowser() {
+ if (!currentSessionId) {
+ showToast('请先打开一个会话');
+ return;
+ }
+ if (!currentCwd) {
+ showToast('当前会话没有可浏览的工作目录');
+ return;
+ }
+ if (fileBrowserState && fileBrowserState.sessionId === currentSessionId) return;
+
+ closeFileBrowser();
+
+ const overlay = document.createElement('div');
+ overlay.className = 'modal-overlay';
+ overlay.id = 'file-browser-overlay';
+ overlay.innerHTML = `
+
+ `;
+
+ document.body.appendChild(overlay);
+
+ const state = {
+ sessionId: currentSessionId,
+ rootPath: currentCwd,
+ currentPath: '',
+ selectedFilePath: '',
+ directoryRequestId: 0,
+ previewRequestId: 0,
+ overlay,
+ panel: overlay.querySelector('.file-browser-panel'),
+ pathEl: overlay.querySelector('[data-browser-path]'),
+ statusEl: overlay.querySelector('[data-browser-status]'),
+ listEl: overlay.querySelector('[data-browser-list]'),
+ previewTitleEl: overlay.querySelector('[data-browser-preview-title]'),
+ previewMetaEl: overlay.querySelector('[data-browser-preview-meta]'),
+ previewEmptyEl: overlay.querySelector('[data-browser-preview-empty]'),
+ previewCodeEl: overlay.querySelector('[data-browser-preview-content]'),
+ upBtn: overlay.querySelector('[data-browser-up]'),
+ refreshBtn: overlay.querySelector('[data-browser-refresh]'),
+ mobileBackBtn: overlay.querySelector('[data-browser-back]'),
+ escapeHandler: null,
+ };
+ fileBrowserState = state;
+
+ state.escapeHandler = (e) => {
+ if (e.key === 'Escape') closeFileBrowser();
+ };
+ document.addEventListener('keydown', state.escapeHandler);
+
+ overlay.querySelector('[data-browser-close]').addEventListener('click', closeFileBrowser);
+ overlay.addEventListener('click', (e) => {
+ if (e.target === overlay) closeFileBrowser();
+ });
+ state.upBtn.addEventListener('click', () => {
+ loadFileBrowserDirectory(getBrowserParentPath(state.currentPath));
+ });
+ state.refreshBtn.addEventListener('click', () => {
+ loadFileBrowserDirectory(state.currentPath, { preservePreview: !!state.selectedFilePath });
+ });
+ state.mobileBackBtn.addEventListener('click', () => {
+ setFileBrowserPreviewMode(false);
+ });
+
+ updateFileBrowserPathBar();
+ renderFileBrowserPreviewEmpty('文件预览', '选择一个文本文件查看内容');
+ loadFileBrowserDirectory('');
+ }
+
function syncAttachmentActions() {
const uploading = uploadingAttachments.length > 0;
if (attachBtn) attachBtn.disabled = uploading;
@@ -794,22 +1285,21 @@
return sessions.filter((s) => normalizeAgent(s.agent) === currentAgent);
}
- function shouldOverlayRuntimeBadge() {
- return window.matchMedia('(max-width: 768px), (pointer: coarse)').matches;
- }
-
function updateCwdBadge() {
if (!chatCwd) return;
if (currentCwd) {
const parts = currentCwd.replace(/\/+$/, '').split('/');
const short = parts.slice(-2).join('/') || currentCwd;
chatCwd.textContent = '~/' + short;
- chatCwd.title = currentCwd;
+ chatCwd.title = `${currentCwd}\n点击浏览目录和文件`;
+ chatCwd.setAttribute('aria-label', `浏览工作目录 ${currentCwd}`);
} else {
chatCwd.textContent = '';
chatCwd.title = '';
+ chatCwd.removeAttribute('aria-label');
}
- chatCwd.hidden = !currentCwd || (currentSessionRunning && shouldOverlayRuntimeBadge());
+ chatCwd.disabled = !currentCwd;
+ chatCwd.hidden = !currentCwd;
}
function setCurrentSessionRunningState(isRunning) {
@@ -862,6 +1352,7 @@
function resetChatView(agent) {
setCurrentAgent(agent);
+ closeFileBrowser();
currentSessionId = null;
loadedHistorySessionId = null;
clearSessionLoading();
@@ -885,6 +1376,9 @@
function applySessionSnapshot(snapshot, options = {}) {
if (!snapshot) return;
+ if (fileBrowserState && (fileBrowserState.sessionId !== snapshot.sessionId || (snapshot.cwd && fileBrowserState.rootPath !== snapshot.cwd))) {
+ closeFileBrowser();
+ }
const preserveStreaming = !!(options.preserveStreaming && isGenerating && snapshot.sessionId === currentSessionId && snapshot.isRunning);
if (isGenerating && !preserveStreaming) {
isGenerating = false;
@@ -1303,6 +1797,15 @@
scheduleRender();
break;
+ case 'content_blocks':
+ if (!isGenerating) startGenerating();
+ if (Array.isArray(msg.blocks)) {
+ if (!window.pendingContentBlocks) window.pendingContentBlocks = [];
+ window.pendingContentBlocks.push(...msg.blocks);
+ scheduleRender();
+ }
+ break;
+
case 'tool_start':
if (!isGenerating) startGenerating();
activeToolCalls.set(msg.toolUseId, { name: msg.name, input: msg.input, kind: msg.kind || null, meta: msg.meta || null, done: false });
@@ -1461,7 +1964,7 @@
break;
case 'cwd_suggestions':
- if (typeof _onCwdSuggestions === 'function') _onCwdSuggestions(msg.paths || []);
+ if (typeof _onCwdSuggestions === 'function') _onCwdSuggestions(msg);
break;
case 'update_info':
@@ -1475,6 +1978,7 @@
isGenerating = true;
setCurrentSessionRunningState(true);
pendingText = '';
+ window.pendingContentBlocks = [];
activeToolCalls.clear();
toolGroupCount = 0;
hasGrouped = false;
@@ -1508,7 +2012,11 @@
setCurrentSessionRunningState(false);
msgInput.focus();
- if (pendingText) flushRender();
+ if (pendingText || (window.pendingContentBlocks && window.pendingContentBlocks.length > 0)) {
+ flushRender();
+ }
+
+ window.pendingContentBlocks = [];
const typing = document.querySelector('.typing-indicator');
if (typing) typing.remove();
@@ -1563,9 +2071,15 @@
if (!streamEl) return;
const bubble = streamEl.querySelector('.msg-bubble');
if (!bubble) return;
- let textDiv = bubble.querySelector('.msg-text');
- if (!textDiv) { textDiv = bubble; }
- textDiv.innerHTML = renderMarkdown(pendingText);
+
+ if (window.pendingContentBlocks && window.pendingContentBlocks.length > 0) {
+ bubble.innerHTML = '';
+ renderAssistantContent(bubble, window.pendingContentBlocks);
+ } else if (pendingText) {
+ let textDiv = bubble.querySelector('.msg-text');
+ if (!textDiv) { textDiv = bubble; }
+ textDiv.innerHTML = renderMarkdown(pendingText);
+ }
scrollToBottom();
}
@@ -1612,7 +2126,7 @@
bubble.insertAdjacentHTML('beforeend', renderAttachmentLabels(attachments));
}
} else {
- bubble.innerHTML = content ? renderMarkdown(content) : '';
+ renderAssistantContent(bubble, content);
if (attachments.length > 0) {
bubble.insertAdjacentHTML('beforeend', renderAttachmentLabels(attachments));
}
@@ -1623,6 +2137,74 @@
return div;
}
+ function renderAssistantContent(bubble, content) {
+ if (!content) return;
+
+ if (typeof content === 'string') {
+ // 尝试检测是否是 JSON 格式的 todo_list
+ const trimmed = content.trim();
+ if (trimmed.startsWith('{') && trimmed.includes('"type"') && trimmed.includes('"todo_list"')) {
+ try {
+ const parsed = JSON.parse(trimmed);
+ if (parsed.type === 'todo_list') {
+ bubble.appendChild(createTodoListElement(parsed));
+ return;
+ }
+ } catch {}
+ }
+ bubble.innerHTML = renderMarkdown(content);
+ return;
+ }
+
+ if (Array.isArray(content)) {
+ content.forEach(block => {
+ if (block.type === 'text') {
+ const textDiv = document.createElement('div');
+ textDiv.innerHTML = renderMarkdown(block.text || '');
+ bubble.appendChild(textDiv);
+ } else if (block.type === 'todo_list') {
+ bubble.appendChild(createTodoListElement(block));
+ }
+ });
+ return;
+ }
+
+ bubble.innerHTML = renderMarkdown(String(content));
+ }
+
+ function createTodoListElement(block) {
+ const container = document.createElement('div');
+ container.className = 'todo-list-container';
+ container.dataset.todoId = block.id || '';
+
+ const list = document.createElement('ul');
+ list.className = 'todo-list';
+
+ if (Array.isArray(block.items)) {
+ block.items.forEach((item, index) => {
+ const li = document.createElement('li');
+ li.className = 'todo-item' + (item.completed ? ' completed' : '');
+
+ const checkbox = document.createElement('input');
+ checkbox.type = 'checkbox';
+ checkbox.className = 'todo-checkbox';
+ checkbox.checked = item.completed || false;
+ checkbox.dataset.index = index;
+
+ const label = document.createElement('span');
+ label.className = 'todo-text';
+ label.textContent = item.text || '';
+
+ li.appendChild(checkbox);
+ li.appendChild(label);
+ list.appendChild(li);
+ });
+ }
+
+ container.appendChild(list);
+ return container;
+ }
+
let renderEpoch = 0;
function toolKind(tool) {
@@ -1631,6 +2213,11 @@
function toolTitle(tool) {
if (tool?.meta?.title) return tool.meta.title;
+ if (toolKind(tool) === 'file_change') {
+ const filePath = tool?.meta?.subtitle || tool?.input?.file_path || '';
+ const action = tool?.input?.new_string && tool?.input?.old_string ? '更新' : '创建';
+ return filePath ? `${action} ${filePath}` : 'File Change';
+ }
return tool?.name || 'Tool';
}
@@ -2419,6 +3006,13 @@
});
});
+ if (chatCwd) {
+ chatCwd.addEventListener('click', () => {
+ if (!currentCwd) return;
+ showFileBrowser();
+ });
+ }
+
// --- Sidebar ---
function openSidebar() {
sidebar.classList.add('open');
@@ -4048,6 +4642,12 @@
function showNewSessionModal() {
const targetAgent = currentAgent;
const targetLabel = AGENT_LABELS[targetAgent] || AGENT_LABELS.claude;
+ const recentCwds = getRecentCwds();
+ let suggestionsRequested = false;
+ let suggestionState = {
+ defaultPath: '',
+ paths: [],
+ };
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.id = 'new-session-overlay';
@@ -4065,8 +4665,11 @@
-
+
+ 正在准备默认目录…
+
+
@@ -4081,33 +4684,111 @@
const cwdInput = overlay.querySelector('#ns-cwd-input');
const cwdList = overlay.querySelector('#ns-cwd-list');
+ const cwdTip = overlay.querySelector('#ns-cwd-tip');
+ const cwdPicks = overlay.querySelector('#ns-cwd-picks');
+ const createBtn = overlay.querySelector('#ns-create-btn');
+ const pickDirBtn = overlay.querySelector('#ns-pick-dir-btn');
- // Populate datalist with recent cwds + server suggestions
- function renderCwdOptions(serverPaths) {
- const recent = getRecentCwds();
+ cwdInput.value = recentCwds[0] || '';
+
+ function getMergedCwdSuggestions() {
const seen = new Set();
- let html = '';
- // Recent cwds first
- for (const p of recent) {
- if (!seen.has(p)) { seen.add(p); html += ``; }
+ const merged = [];
+ for (const candidate of [...recentCwds, suggestionState.defaultPath, ...(suggestionState.paths || [])]) {
+ const pathValue = String(candidate || '').trim();
+ if (!pathValue || seen.has(pathValue)) continue;
+ seen.add(pathValue);
+ merged.push(pathValue);
}
- // Server suggestions
- for (const p of (serverPaths || [])) {
- if (!seen.has(p)) { seen.add(p); html += ``; }
- }
- cwdList.innerHTML = html;
+ return merged;
}
- // Pre-fill with local recent cwds immediately
- renderCwdOptions([]);
+ function getEffectiveCwd() {
+ return cwdInput.value.trim() || suggestionState.defaultPath || null;
+ }
- // Fetch server suggestions on focus
- cwdInput.addEventListener('focus', () => {
- _onCwdSuggestions = (paths) => { renderCwdOptions(paths); };
+ function renderCwdOptions() {
+ const merged = getMergedCwdSuggestions();
+ cwdList.innerHTML = merged
+ .map((pathValue, index) => ``)
+ .join('');
+
+ const quickPickPaths = merged.slice(0, 6);
+ cwdPicks.innerHTML = quickPickPaths.map((pathValue) => `
+
+ `).join('');
+
+ cwdPicks.querySelectorAll('.modal-quick-pick').forEach((button) => {
+ button.addEventListener('click', () => {
+ const pathValue = button.dataset.path || '';
+ if (!pathValue) return;
+ cwdInput.value = pathValue;
+ cwdInput.focus();
+ });
+ });
+
+ const fallbackPath = suggestionState.defaultPath || '';
+ cwdTip.textContent = fallbackPath
+ ? `留空时默认使用 ${fallbackPath}`
+ : '可手动输入路径,也可以点按钮选择目录';
+ }
+
+ function requestCwdSuggestions() {
+ if (suggestionsRequested) return;
+ suggestionsRequested = true;
+ _onCwdSuggestions = (payload) => {
+ suggestionState = {
+ defaultPath: String(payload?.defaultPath || '').trim(),
+ paths: Array.isArray(payload?.paths) ? payload.paths : [],
+ };
+ if (!cwdInput.value.trim()) {
+ cwdInput.value = recentCwds[0] || suggestionState.defaultPath || '';
+ }
+ renderCwdOptions();
+ };
send({ type: 'list_cwd_suggestions' });
+ }
+
+ renderCwdOptions();
+ requestCwdSuggestions();
+
+ function createSession() {
+ const cwd = getEffectiveCwd();
+ close();
+ if (cwd) saveRecentCwd(cwd);
+ send({ type: 'new_session', cwd, agent: targetAgent, mode: currentMode });
+ }
+
+ pickDirBtn.addEventListener('click', () => {
+ showDirectoryPicker({
+ title: '选择工作目录',
+ initialPath: getEffectiveCwd() || '',
+ onChoose: (selectedPath) => {
+ cwdInput.value = selectedPath;
+ cwdInput.focus();
+ },
+ });
+ });
+
+ cwdInput.addEventListener('focus', () => {
+ if (!suggestionState.defaultPath && (!suggestionState.paths || suggestionState.paths.length === 0)) {
+ requestCwdSuggestions();
+ }
+ });
+ cwdInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ createSession();
+ }
});
function close() {
+ closeDirectoryPicker();
overlay.remove();
_onCwdSuggestions = null;
}
@@ -4115,13 +4796,7 @@
overlay.querySelector('#ns-close-btn').addEventListener('click', close);
overlay.querySelector('#ns-cancel-btn').addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
-
- overlay.querySelector('#ns-create-btn').addEventListener('click', () => {
- const cwd = cwdInput.value.trim() || null;
- close();
- if (cwd) saveRecentCwd(cwd);
- send({ type: 'new_session', cwd, agent: targetAgent, mode: currentMode });
- });
+ createBtn.addEventListener('click', createSession);
cwdInput.focus();
}
diff --git a/public/index.html b/public/index.html
index 9cec4f1..1f55ccd 100644
--- a/public/index.html
+++ b/public/index.html
@@ -66,7 +66,7 @@
运行中
-
+