feat: update session workspace flow and web ui

This commit is contained in:
shiyue
2026-03-30 04:31:25 +08:00
parent a29af2767e
commit a3df0cc6f0
9 changed files with 1546 additions and 55 deletions

View File

@@ -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 = '<div class="modal-empty">这个目录里没有可进入的子目录,直接使用当前目录也可以。</div>';
return;
}
directoryPickerState.listEl.innerHTML = safeEntries.map((entry) => {
const metaParts = [entry.symlink ? '链接目录' : '目录'];
if (entry.updatedAt) metaParts.push(timeAgo(entry.updatedAt));
return `
<button
class="file-browser-item directory"
type="button"
data-path="${escapeHtml(entry.path || '')}"
>
<span class="file-browser-item-icon" aria-hidden="true">DIR</span>
<span class="file-browser-item-copy">
<span class="file-browser-item-name">${escapeHtml(entry.name || getPathLeaf(entry.path) || '')}</span>
<span class="file-browser-item-meta">${escapeHtml(metaParts.join(' · '))}</span>
</span>
</button>
`;
}).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 = '<div class="modal-loading">正在读取目录…</div>';
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 = `<div class="modal-empty">${escapeHtml(err.message || '目录读取失败')}</div>`;
setDirectoryPickerStatus(err.message || '目录读取失败', 'error');
}
}
function showDirectoryPicker(options = {}) {
closeDirectoryPicker();
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.id = 'directory-picker-overlay';
overlay.innerHTML = `
<div class="modal-panel modal-panel-wide directory-picker-panel">
<div class="modal-header">
<span class="modal-title">${escapeHtml(options.title || '选择工作目录')}</span>
<button class="modal-close-btn" type="button" data-picker-close>✕</button>
</div>
<div class="modal-body file-browser-body">
<div class="file-browser-toolbar">
<button class="file-browser-toolbar-btn" type="button" data-picker-up>上一级</button>
<button class="file-browser-toolbar-btn" type="button" data-picker-refresh>刷新</button>
<div class="file-browser-path" data-picker-path></div>
</div>
<div class="file-browser-status" data-picker-status>正在读取目录…</div>
<div class="file-browser-list directory-picker-list" data-picker-list></div>
</div>
<div class="modal-footer">
<button class="modal-btn-secondary" type="button" data-picker-cancel>取消</button>
<button class="modal-btn-primary" type="button" data-picker-choose disabled>使用当前目录</button>
</div>
</div>
`;
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 = '<div class="modal-empty">这个目录里还没有可显示的文件</div>';
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 `
<button
class="file-browser-item${entry.kind === 'directory' ? ' directory' : ''}"
type="button"
data-kind="${escapeHtml(entry.kind)}"
data-path="${escapeHtml(entry.path || '')}"
>
<span class="file-browser-item-icon" aria-hidden="true">${entry.kind === 'directory' ? 'DIR' : (entry.previewableHint ? 'TXT' : 'FILE')}</span>
<span class="file-browser-item-copy">
<span class="file-browser-item-name">${escapeHtml(entry.name || '')}</span>
<span class="file-browser-item-meta">${escapeHtml(metaParts.join(' · '))}</span>
</span>
</button>
`;
}).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 = '<div class="modal-loading">正在读取目录…</div>';
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 = `<div class="modal-empty">${escapeHtml(err.message || '目录读取失败')}</div>`;
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 = `
<div class="modal-panel modal-panel-wide file-browser-panel">
<div class="modal-header">
<span class="modal-title">文件浏览器</span>
<button class="modal-close-btn" type="button" data-browser-close>✕</button>
</div>
<div class="modal-body file-browser-body">
<div class="file-browser-toolbar">
<button class="file-browser-toolbar-btn" type="button" data-browser-up>上一级</button>
<button class="file-browser-toolbar-btn" type="button" data-browser-refresh>刷新</button>
<div class="file-browser-path" data-browser-path></div>
</div>
<div class="file-browser-status" data-browser-status>正在准备目录…</div>
<div class="file-browser-layout">
<section class="file-browser-pane file-browser-list-pane">
<div class="file-browser-pane-title">目录与文件</div>
<div class="file-browser-list" data-browser-list></div>
</section>
<section class="file-browser-pane file-browser-preview-pane">
<div class="file-browser-preview-header">
<button class="file-browser-mobile-back" type="button" data-browser-back>返回目录</button>
<div class="file-browser-preview-copy">
<div class="file-browser-preview-title" data-browser-preview-title>文件预览</div>
<div class="file-browser-preview-meta" data-browser-preview-meta>选择一个文本文件查看内容</div>
</div>
</div>
<div class="file-browser-preview-empty" data-browser-preview-empty>选择一个文本文件查看内容</div>
<pre class="file-browser-preview-content" data-browser-preview-content hidden></pre>
</section>
</div>
</div>
</div>
`;
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 @@
<label class="modal-field-label" for="ns-cwd-input">工作目录</label>
<div class="modal-field-row">
<input type="text" id="ns-cwd-input" class="modal-text-input" placeholder="例如 /home/user/project" list="ns-cwd-list" autocomplete="off">
<datalist id="ns-cwd-list"></datalist>
<button type="button" class="modal-btn-secondary modal-btn-inline" id="ns-pick-dir-btn">选择目录</button>
</div>
<div class="modal-field-hint" id="ns-cwd-tip">正在准备默认目录…</div>
<div class="modal-quick-picks" id="ns-cwd-picks"></div>
<datalist id="ns-cwd-list"></datalist>
</div>
</div>
</div>
@@ -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 += `<option value="${escapeHtml(p)}" label="最近"></option>`; }
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 += `<option value="${escapeHtml(p)}"></option>`; }
}
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) => `<option value="${escapeHtml(pathValue)}"${index < recentCwds.length ? ' label="最近"' : ''}></option>`)
.join('');
const quickPickPaths = merged.slice(0, 6);
cwdPicks.innerHTML = quickPickPaths.map((pathValue) => `
<button
type="button"
class="modal-quick-pick"
data-path="${escapeHtml(pathValue)}"
title="${escapeHtml(pathValue)}"
>${escapeHtml(pathValue)}</button>
`).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();
}