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 = ``; + 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 = ``; + 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 @@ - +