@qnote/q-ai-note 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/web/app.js CHANGED
@@ -10,6 +10,16 @@ const state = {
10
10
  chats: [],
11
11
  settings: {},
12
12
  pendingAction: null,
13
+ nodeEntities: [],
14
+ nodeEntityStats: null,
15
+ selectedNodeId: null,
16
+ nodeEntityFilter: 'all',
17
+ editingNodeEntityId: null,
18
+ nodeEntityFormExpanded: false,
19
+ sandboxPresentationMode: false,
20
+ sandboxFullscreenMode: false,
21
+ workTreeViewMode: 'full',
22
+ workItemShowAssignee: false,
13
23
  workItemSearch: '',
14
24
  workItemStatusFilter: 'all',
15
25
  diarySearch: '',
@@ -20,7 +30,102 @@ const state = {
20
30
  };
21
31
 
22
32
  const expandedNodes = new Set();
23
- let selectedNodeId = null;
33
+ let quickChatPopover = null;
34
+ let sandboxActionHandler = null;
35
+ let sandboxEscLocked = false;
36
+ let resizeRenderTimer = null;
37
+ const WORK_ITEM_SHOW_ASSIGNEE_STORAGE_KEY = 'q-ai-note.work-item.show-assignee';
38
+
39
+ function loadWorkItemAssigneePreference() {
40
+ try {
41
+ const raw = window.localStorage.getItem(WORK_ITEM_SHOW_ASSIGNEE_STORAGE_KEY);
42
+ if (raw === 'true') state.workItemShowAssignee = true;
43
+ if (raw === 'false') state.workItemShowAssignee = false;
44
+ } catch {
45
+ // Ignore storage failures in restricted environments.
46
+ }
47
+ }
48
+
49
+ function persistWorkItemAssigneePreference() {
50
+ try {
51
+ window.localStorage.setItem(
52
+ WORK_ITEM_SHOW_ASSIGNEE_STORAGE_KEY,
53
+ state.workItemShowAssignee ? 'true' : 'false',
54
+ );
55
+ } catch {
56
+ // Ignore storage failures in restricted environments.
57
+ }
58
+ }
59
+
60
+ function applySandboxLayoutMode() {
61
+ const layout = document.getElementById('sandbox-layout');
62
+ const toggleBtn = document.getElementById('toggle-sandbox-present-btn');
63
+ if (!layout || !toggleBtn) return;
64
+ const isPresentation = Boolean(state.sandboxPresentationMode);
65
+ layout.classList.toggle('presentation-mode', isPresentation);
66
+ toggleBtn.textContent = isPresentation ? '退出展示态' : '展示态布局';
67
+ toggleBtn.setAttribute('aria-pressed', isPresentation ? 'true' : 'false');
68
+ }
69
+
70
+ function getSandboxLayoutElement() {
71
+ return document.getElementById('sandbox-layout');
72
+ }
73
+
74
+ function getSandboxFullscreenElement() {
75
+ return document.getElementById('page-sandbox-detail');
76
+ }
77
+
78
+ async function syncSandboxEscLock(enabled) {
79
+ const keyboardApi = navigator?.keyboard;
80
+ if (!keyboardApi) return;
81
+ try {
82
+ if (enabled && !sandboxEscLocked) {
83
+ await keyboardApi.lock(['Escape']);
84
+ sandboxEscLocked = true;
85
+ return;
86
+ }
87
+ if (!enabled && sandboxEscLocked) {
88
+ keyboardApi.unlock();
89
+ sandboxEscLocked = false;
90
+ }
91
+ } catch {
92
+ // Ignore platforms without keyboard lock support.
93
+ }
94
+ }
95
+
96
+ function applySandboxFullscreenState() {
97
+ const layout = getSandboxLayoutElement();
98
+ const fullscreenRoot = getSandboxFullscreenElement();
99
+ const toggleBtn = document.getElementById('toggle-sandbox-fullscreen-btn');
100
+ const isFullscreen = Boolean(fullscreenRoot && document.fullscreenElement === fullscreenRoot);
101
+ state.sandboxFullscreenMode = isFullscreen;
102
+ if (layout) {
103
+ layout.classList.toggle('is-fullscreen', isFullscreen);
104
+ }
105
+ if (fullscreenRoot) {
106
+ fullscreenRoot.classList.toggle('is-sandbox-fullscreen', isFullscreen);
107
+ }
108
+ if (toggleBtn) {
109
+ toggleBtn.textContent = isFullscreen ? '退出全屏' : '全屏';
110
+ toggleBtn.setAttribute('aria-pressed', isFullscreen ? 'true' : 'false');
111
+ }
112
+ void syncSandboxEscLock(isFullscreen);
113
+ }
114
+
115
+ async function toggleSandboxFullscreen() {
116
+ const fullscreenRoot = getSandboxFullscreenElement();
117
+ if (!fullscreenRoot) return;
118
+ try {
119
+ if (document.fullscreenElement === fullscreenRoot) {
120
+ await document.exitFullscreen();
121
+ } else {
122
+ await fullscreenRoot.requestFullscreen();
123
+ }
124
+ } catch (_error) {
125
+ // Keep UI fallback silent for browsers/environments that block fullscreen.
126
+ applySandboxFullscreenState();
127
+ }
128
+ }
24
129
 
25
130
  function applyWorkItemFilters(items) {
26
131
  const query = state.workItemSearch.trim().toLowerCase();
@@ -69,15 +174,46 @@ function expandAllNodes() {
69
174
  items.forEach(item => expandedNodes.add(item.id));
70
175
  }
71
176
 
177
+ function getRootNodeIds(items) {
178
+ return items.filter((item) => !item.parent_id).map((item) => item.id);
179
+ }
180
+
181
+ function applyWorkTreeViewMode(mode) {
182
+ state.workTreeViewMode = mode === 'report' ? 'report' : mode === 'dense' ? 'dense' : 'full';
183
+ const selector = document.getElementById('work-tree-view-mode');
184
+ if (selector instanceof HTMLSelectElement) {
185
+ selector.value = state.workTreeViewMode;
186
+ }
187
+ const items = state.currentSandbox?.items || [];
188
+ expandedNodes.clear();
189
+ if (state.workTreeViewMode === 'report') {
190
+ getRootNodeIds(items).forEach((id) => expandedNodes.add(id));
191
+ } else {
192
+ expandAllNodes();
193
+ }
194
+ }
195
+
196
+ function applyWorkItemAssigneeToggle() {
197
+ const toggle = document.getElementById('work-item-show-assignee-toggle');
198
+ if (toggle instanceof HTMLInputElement) {
199
+ toggle.checked = Boolean(state.workItemShowAssignee);
200
+ }
201
+ }
202
+
72
203
  function renderWorkTree() {
73
204
  const tree = document.getElementById('work-tree');
74
205
  if (!tree || !state.currentSandbox) return;
75
206
 
76
207
  const allItems = state.currentSandbox.items || [];
77
208
  const items = applyWorkItemFilters(allItems);
209
+ const entitySummaryByNodeId = buildNodeEntitySummaryByNodeId();
78
210
 
79
211
  if (expandedNodes.size === 0 && allItems.length > 0) {
80
- expandAllNodes();
212
+ if (state.workTreeViewMode === 'report') {
213
+ getRootNodeIds(allItems).forEach((id) => expandedNodes.add(id));
214
+ } else {
215
+ expandAllNodes();
216
+ }
81
217
  }
82
218
 
83
219
  if (allItems.length === 0) {
@@ -88,6 +224,7 @@ function renderWorkTree() {
88
224
  mountWorkTree('work-tree', {
89
225
  items,
90
226
  expandedIds: Array.from(expandedNodes),
227
+ entitySummaryByNodeId,
91
228
  onToggleExpand: (id) => {
92
229
  if (expandedNodes.has(id)) {
93
230
  expandedNodes.delete(id);
@@ -110,17 +247,48 @@ function renderWorkTree() {
110
247
  onEdit: (id) => {
111
248
  editWorkItem(id);
112
249
  },
250
+ onSelect: (id) => {
251
+ showNodeEntityDrawer(id);
252
+ },
113
253
  onDelete: async (id) => {
114
254
  if (confirm('确定删除此任务?')) {
115
255
  await apiRequest(`${API_BASE}/items/${id}`, { method: 'DELETE' });
116
256
  await loadSandbox(state.currentSandbox.id);
117
257
  }
118
- }
258
+ },
259
+ onQuickChat: (id, el) => {
260
+ openQuickChatPopover(id, el);
261
+ },
262
+ renderMode: state.workTreeViewMode === 'dense' ? 'dense' : 'card',
263
+ showAssignee: state.workItemShowAssignee,
119
264
  });
120
265
 
121
266
  populateParentSelect(allItems);
122
267
  }
123
268
 
269
+ function buildNodeEntitySummaryByNodeId() {
270
+ const summaryByNodeId = {};
271
+ for (const row of state.nodeEntities || []) {
272
+ const nodeId = String(row.work_item_id || '');
273
+ if (!nodeId) continue;
274
+ if (!summaryByNodeId[nodeId]) {
275
+ summaryByNodeId[nodeId] = { issue: 0, knowledge: 0, capability: 0, issue_open: 0, issue_closed: 0 };
276
+ }
277
+ if (row.entity_type === 'issue') {
278
+ summaryByNodeId[nodeId].issue += 1;
279
+ const status = String(row.status || '');
280
+ if (status === 'resolved' || status === 'closed') {
281
+ summaryByNodeId[nodeId].issue_closed += 1;
282
+ } else {
283
+ summaryByNodeId[nodeId].issue_open += 1;
284
+ }
285
+ }
286
+ if (row.entity_type === 'knowledge') summaryByNodeId[nodeId].knowledge += 1;
287
+ if (row.entity_type === 'capability') summaryByNodeId[nodeId].capability += 1;
288
+ }
289
+ return summaryByNodeId;
290
+ }
291
+
124
292
  function populateParentSelect(items) {
125
293
  const select = document.getElementById('new-item-parent');
126
294
  if (!select) return;
@@ -129,6 +297,422 @@ function populateParentSelect(items) {
129
297
  items.map(i => `<option value="${i.id}">${safeText(i.name)}</option>`).join('');
130
298
  }
131
299
 
300
+ function renderMarkdownSnippet(text) {
301
+ const escaped = safeText(String(text || ''));
302
+ return escaped.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_m, label, url) => {
303
+ const safeUrl = safeText(url);
304
+ const safeLabel = safeText(label);
305
+ return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${safeLabel}</a>`;
306
+ });
307
+ }
308
+
309
+ function getNodeById(nodeId) {
310
+ return state.currentSandbox?.items?.find((item) => item.id === nodeId) || null;
311
+ }
312
+
313
+ function getNodeEntitiesByNodeId(nodeId) {
314
+ return (state.nodeEntities || []).filter((row) => row.work_item_id === nodeId);
315
+ }
316
+
317
+ function closeNodeEntityDrawer() {
318
+ const drawer = document.getElementById('node-entity-drawer');
319
+ if (!drawer) return;
320
+ drawer.classList.add('hidden');
321
+ state.selectedNodeId = null;
322
+ state.nodeEntityFilter = 'all';
323
+ state.editingNodeEntityId = null;
324
+ state.nodeEntityFormExpanded = false;
325
+ }
326
+
327
+ function setNodeEntityFormExpanded(expanded) {
328
+ state.nodeEntityFormExpanded = expanded;
329
+ const form = document.getElementById('drawer-create-form');
330
+ const toggleBtn = document.getElementById('toggle-node-entity-form-btn');
331
+ form?.classList.toggle('hidden', !expanded);
332
+ if (toggleBtn) {
333
+ toggleBtn.textContent = expanded ? '收起表单' : '+ 新建记录';
334
+ }
335
+ }
336
+
337
+ async function deleteNodeEntityRecord(entityId) {
338
+ if (!state.currentSandbox) return;
339
+ await apiRequest(`${API_BASE}/sandboxes/${state.currentSandbox.id}/entities/${entityId}`, {
340
+ method: 'DELETE',
341
+ });
342
+ await loadSandbox(state.currentSandbox.id);
343
+ if (state.selectedNodeId) {
344
+ showNodeEntityDrawer(state.selectedNodeId);
345
+ }
346
+ }
347
+
348
+ function renderNodeEntitySummary(nodeId) {
349
+ const container = document.getElementById('node-entity-summary');
350
+ if (!container) return;
351
+ const rows = getNodeEntitiesByNodeId(nodeId);
352
+ const issues = rows.filter((row) => row.entity_type === 'issue');
353
+ const knowledges = rows.filter((row) => row.entity_type === 'knowledge');
354
+ const capabilities = rows.filter((row) => row.entity_type === 'capability');
355
+ const openIssues = issues.filter((row) => row.status === 'open' || row.status === 'in_progress' || row.status === 'blocked').length;
356
+ container.innerHTML = `
357
+ <div class="summary-card"><div class="label">Issue</div><div class="value">${issues.length}</div></div>
358
+ <div class="summary-card"><div class="label">Open Issue</div><div class="value">${openIssues}</div></div>
359
+ <div class="summary-card"><div class="label">Knowledge</div><div class="value">${knowledges.length}</div></div>
360
+ <div class="summary-card"><div class="label">Capability</div><div class="value">${capabilities.length}</div></div>
361
+ `;
362
+ }
363
+
364
+ function renderNodeEntityList(nodeId) {
365
+ const container = document.getElementById('node-entity-list');
366
+ if (!container) return;
367
+ const allRows = getNodeEntitiesByNodeId(nodeId);
368
+ const rows = state.nodeEntityFilter === 'all'
369
+ ? allRows
370
+ : allRows.filter((row) => row.entity_type === state.nodeEntityFilter);
371
+ if (!rows.length) {
372
+ container.innerHTML = '<div class="empty-state"><p>当前节点还没有 issue/knowledge/capability</p></div>';
373
+ return;
374
+ }
375
+ container.innerHTML = rows.map((row) => `
376
+ <div class="entity-card">
377
+ <div class="entity-card-header">
378
+ <div>
379
+ <span class="entity-type-pill">${safeText(row.entity_type)}</span>
380
+ <strong>${safeText(row.title || '-')}</strong>
381
+ </div>
382
+ <div class="entity-card-actions">
383
+ <button class="btn btn-secondary btn-sm" data-entity-edit-id="${safeText(row.id)}">编辑</button>
384
+ <button class="btn btn-secondary btn-sm" data-entity-delete-id="${safeText(row.id)}">删除</button>
385
+ </div>
386
+ </div>
387
+ <div class="entity-meta">
388
+ ${row.status ? `<span class="entity-status-pill ${safeText(row.status)}">${safeText(row.status)}</span>` : ''}${row.priority ? `· ${safeText(row.priority)}` : ''} ${row.assignee ? `· @${safeText(row.assignee)}` : ''}
389
+ </div>
390
+ <div class="entity-content">${renderMarkdownSnippet(row.content_md || '')}</div>
391
+ </div>
392
+ `).join('');
393
+
394
+ container.querySelectorAll('[data-entity-delete-id]').forEach((el) => {
395
+ el.addEventListener('click', async (e) => {
396
+ e.preventDefault();
397
+ const id = el.getAttribute('data-entity-delete-id');
398
+ if (!id) return;
399
+ if (!confirm('确定删除这条记录?')) return;
400
+ await deleteNodeEntityRecord(id);
401
+ });
402
+ });
403
+
404
+ container.querySelectorAll('[data-entity-edit-id]').forEach((el) => {
405
+ el.addEventListener('click', (e) => {
406
+ e.preventDefault();
407
+ const id = el.getAttribute('data-entity-edit-id');
408
+ if (!id) return;
409
+ const row = allRows.find((item) => item.id === id);
410
+ if (!row) return;
411
+ startEditNodeEntity(row);
412
+ });
413
+ });
414
+ }
415
+
416
+ function resetNodeEntityForm() {
417
+ state.editingNodeEntityId = null;
418
+ const typeInput = document.getElementById('entity-type-select');
419
+ const titleInput = document.getElementById('entity-title-input');
420
+ const contentInput = document.getElementById('entity-content-input');
421
+ const assigneeInput = document.getElementById('entity-assignee-input');
422
+ const statusInput = document.getElementById('entity-status-select');
423
+ const priorityInput = document.getElementById('entity-priority-select');
424
+ const capabilityTypeInput = document.getElementById('entity-capability-type-input');
425
+ if (typeInput) typeInput.value = 'issue';
426
+ if (titleInput) titleInput.value = '';
427
+ if (contentInput) contentInput.value = '';
428
+ if (assigneeInput) assigneeInput.value = '';
429
+ if (statusInput) statusInput.value = '';
430
+ if (priorityInput) priorityInput.value = '';
431
+ if (capabilityTypeInput) capabilityTypeInput.value = '';
432
+
433
+ applyEntityFormMode();
434
+ const submitBtn = document.getElementById('create-node-entity-btn');
435
+ if (submitBtn) submitBtn.textContent = '添加记录';
436
+ const cancelBtn = document.getElementById('cancel-edit-entity-btn');
437
+ cancelBtn?.classList.add('hidden');
438
+ setNodeEntityFormExpanded(false);
439
+ }
440
+
441
+ function startEditNodeEntity(row) {
442
+ state.editingNodeEntityId = row.id;
443
+ setNodeEntityFormExpanded(true);
444
+ const typeInput = document.getElementById('entity-type-select');
445
+ const titleInput = document.getElementById('entity-title-input');
446
+ const contentInput = document.getElementById('entity-content-input');
447
+ const assigneeInput = document.getElementById('entity-assignee-input');
448
+ const statusInput = document.getElementById('entity-status-select');
449
+ const priorityInput = document.getElementById('entity-priority-select');
450
+ const capabilityTypeInput = document.getElementById('entity-capability-type-input');
451
+ if (typeInput) typeInput.value = row.entity_type || 'issue';
452
+ applyEntityFormMode();
453
+ if (titleInput) titleInput.value = row.title || '';
454
+ if (contentInput) contentInput.value = row.content_md || '';
455
+ if (assigneeInput) assigneeInput.value = row.assignee || '';
456
+ if (statusInput) statusInput.value = row.status || '';
457
+ if (priorityInput) priorityInput.value = row.priority || '';
458
+ if (capabilityTypeInput) capabilityTypeInput.value = row.capability_type || '';
459
+
460
+ const submitBtn = document.getElementById('create-node-entity-btn');
461
+ if (submitBtn) submitBtn.textContent = '保存修改';
462
+ const cancelBtn = document.getElementById('cancel-edit-entity-btn');
463
+ cancelBtn?.classList.remove('hidden');
464
+ titleInput?.focus();
465
+ }
466
+
467
+ function showNodeEntityDrawer(nodeId) {
468
+ const drawer = document.getElementById('node-entity-drawer');
469
+ const title = document.getElementById('drawer-node-title');
470
+ const node = getNodeById(nodeId);
471
+ if (!drawer || !title || !node) return;
472
+ state.selectedNodeId = nodeId;
473
+ state.nodeEntityFilter = 'all';
474
+ title.textContent = node.name || nodeId;
475
+ renderNodeEntitySummary(nodeId);
476
+ renderNodeEntityFilterTabs();
477
+ renderNodeEntityList(nodeId);
478
+ resetNodeEntityForm();
479
+ drawer.classList.remove('hidden');
480
+ setTimeout(() => drawer.focus(), 0);
481
+ }
482
+
483
+ function renderNodeEntityFilterTabs() {
484
+ const tabs = document.getElementById('entity-filter-tabs');
485
+ if (!tabs) return;
486
+ tabs.querySelectorAll('[data-entity-filter]').forEach((el) => {
487
+ const filter = el.getAttribute('data-entity-filter');
488
+ el.classList.toggle('active', filter === state.nodeEntityFilter);
489
+ });
490
+ }
491
+
492
+ function getStatusOptionsByEntityType(entityType) {
493
+ if (entityType === 'issue') {
494
+ return [
495
+ { value: '', label: '状态(Issue,默认 open)' },
496
+ { value: 'open', label: 'open' },
497
+ { value: 'in_progress', label: 'in_progress' },
498
+ { value: 'blocked', label: 'blocked' },
499
+ { value: 'resolved', label: 'resolved' },
500
+ { value: 'closed', label: 'closed' },
501
+ ];
502
+ }
503
+ if (entityType === 'capability') {
504
+ return [
505
+ { value: '', label: '状态(Capability,默认 building)' },
506
+ { value: 'building', label: 'building' },
507
+ { value: 'ready', label: 'ready' },
508
+ ];
509
+ }
510
+ return [{ value: '', label: '无状态(Knowledge)' }];
511
+ }
512
+
513
+ function applyEntityFormMode() {
514
+ const type = document.getElementById('entity-type-select')?.value || 'issue';
515
+ const assigneeGroup = document.getElementById('entity-assignee-group');
516
+ const statusGroup = document.getElementById('entity-status-group');
517
+ const priorityGroup = document.getElementById('entity-priority-group');
518
+ const capabilityTypeGroup = document.getElementById('entity-capability-type-group');
519
+ const hint = document.getElementById('entity-form-hint');
520
+ const statusSelect = document.getElementById('entity-status-select');
521
+
522
+ if (statusSelect) {
523
+ statusSelect.innerHTML = getStatusOptionsByEntityType(type)
524
+ .map((option) => `<option value="${safeText(option.value)}">${safeText(option.label)}</option>`)
525
+ .join('');
526
+ }
527
+
528
+ assigneeGroup?.classList.toggle('hidden', type !== 'issue');
529
+ priorityGroup?.classList.toggle('hidden', type !== 'issue');
530
+ capabilityTypeGroup?.classList.toggle('hidden', type !== 'capability');
531
+ statusGroup?.classList.toggle('hidden', false);
532
+
533
+ if (hint) {
534
+ hint.textContent = type === 'issue'
535
+ ? 'Issue 推荐填写状态/优先级/负责人。'
536
+ : type === 'knowledge'
537
+ ? 'Knowledge 可仅保存链接或少量 Markdown。'
538
+ : 'Capability 建议填写能力类型与简述。';
539
+ }
540
+ }
541
+
542
+ function closeQuickChatPopover() {
543
+ if (!quickChatPopover) return;
544
+ quickChatPopover.remove();
545
+ quickChatPopover = null;
546
+ }
547
+
548
+ function getNodePath(nodeId) {
549
+ const items = state.currentSandbox?.items || [];
550
+ const byId = new Map(items.map((item) => [item.id, item]));
551
+ const path = [];
552
+ let cursor = byId.get(nodeId);
553
+ while (cursor) {
554
+ path.unshift(cursor.name || cursor.id);
555
+ cursor = cursor.parent_id ? byId.get(cursor.parent_id) : null;
556
+ }
557
+ return path;
558
+ }
559
+
560
+ function composeQuickChatContent(nodeId, userQuestion) {
561
+ const node = getNodeById(nodeId);
562
+ if (!node || !state.currentSandbox) {
563
+ return userQuestion;
564
+ }
565
+ const rows = getNodeEntitiesByNodeId(nodeId);
566
+ const issueCount = rows.filter((row) => row.entity_type === 'issue').length;
567
+ const knowledgeCount = rows.filter((row) => row.entity_type === 'knowledge').length;
568
+ const capabilityCount = rows.filter((row) => row.entity_type === 'capability').length;
569
+ const path = getNodePath(nodeId).join(' > ');
570
+ return [
571
+ '[NODE_CONTEXT]',
572
+ `sandbox_id=${state.currentSandbox.id}`,
573
+ `node_type=work_item`,
574
+ `node_id=${node.id}`,
575
+ `node_name=${node.name || ''}`,
576
+ `node_path=${path}`,
577
+ `entity_counts=issue:${issueCount},knowledge:${knowledgeCount},capability:${capabilityCount}`,
578
+ '[/NODE_CONTEXT]',
579
+ '',
580
+ '用户问题:',
581
+ userQuestion,
582
+ ].join('\n');
583
+ }
584
+
585
+ async function sendSandboxChatMessage(content, options = {}) {
586
+ if (!content || !state.currentSandbox) return;
587
+ const messages = document.getElementById('sandbox-chat-messages');
588
+ const btn = document.getElementById('sandbox-send-btn');
589
+ if (!messages || !btn) return;
590
+
591
+ const displayContent = options.displayContent || content;
592
+ const payloadContent = options.payloadContent || content;
593
+ messages.insertAdjacentHTML('beforeend', `<div class="chat-message user">${escapeHtml(displayContent)}</div>`);
594
+ const loadingMessage = appendLoadingMessage(messages);
595
+ messages.scrollTop = messages.scrollHeight;
596
+
597
+ const originalText = btn.textContent;
598
+ setButtonState(btn, { disabled: true, text: '思考中' });
599
+
600
+ try {
601
+ const history = state.chats
602
+ .filter(c => c.role)
603
+ .slice(-10)
604
+ .map(c => ({ role: c.role, content: typeof c.content === 'string' ? c.content : JSON.stringify(c.content) }));
605
+
606
+ const result = await apiRequest(`${API_BASE}/chats/react`, {
607
+ method: 'POST',
608
+ body: JSON.stringify({
609
+ content: payloadContent,
610
+ sandbox_id: state.currentSandbox.id,
611
+ history,
612
+ pending_action: state.pendingAction
613
+ }),
614
+ });
615
+
616
+ loadingMessage?.remove();
617
+ if (typeof sandboxActionHandler === 'function') {
618
+ await sandboxActionHandler(result.action, state.currentSandbox.id);
619
+ }
620
+ await loadSandbox(state.currentSandbox.id);
621
+ } catch (error) {
622
+ loadingMessage?.remove();
623
+ messages.insertAdjacentHTML('beforeend', `<div class="chat-message assistant error">❌ 错误: ${escapeHtml(error.message)}</div>`);
624
+ messages.scrollTop = messages.scrollHeight;
625
+ } finally {
626
+ setButtonState(btn, { disabled: false, text: originalText || '发送' });
627
+ }
628
+ }
629
+
630
+ function openQuickChatPopover(nodeId, anchorEl) {
631
+ if (!state.currentSandbox) return;
632
+ closeQuickChatPopover();
633
+ const container = document.querySelector('.sandbox-tree-section');
634
+ if (!container || !(anchorEl instanceof HTMLElement)) return;
635
+ const node = getNodeById(nodeId);
636
+ if (!node) return;
637
+
638
+ const popover = document.createElement('div');
639
+ popover.className = 'quick-chat-popover';
640
+ popover.innerHTML = `
641
+ <div class="quick-chat-header">
642
+ <span>快捷提问 · ${safeText(node.name || node.id)}</span>
643
+ <button class="btn btn-secondary btn-sm" data-quick-chat-cancel>关闭</button>
644
+ </div>
645
+ <textarea placeholder="输入多行问题,确认后会自动提交到右侧聊天区(Ctrl/Cmd+Enter 提交)"></textarea>
646
+ <div class="quick-chat-meta"><span data-quick-chat-count>0</span>/1000</div>
647
+ <div class="quick-chat-actions">
648
+ <button class="btn btn-secondary btn-sm" data-quick-chat-cancel>取消</button>
649
+ <button class="btn btn-primary btn-sm" data-quick-chat-submit disabled>确认提交</button>
650
+ </div>
651
+ `;
652
+ container.appendChild(popover);
653
+ quickChatPopover = popover;
654
+
655
+ const containerRect = container.getBoundingClientRect();
656
+ const anchorRect = anchorEl.getBoundingClientRect();
657
+ const popoverRect = popover.getBoundingClientRect();
658
+ const horizontalPadding = 8;
659
+ const verticalPadding = 8;
660
+ const preferredLeft = anchorRect.right - containerRect.left - popoverRect.width + 20;
661
+ const preferredTop = anchorRect.bottom - containerRect.top + 6;
662
+ const maxLeft = Math.max(horizontalPadding, containerRect.width - popoverRect.width - horizontalPadding);
663
+ const maxTop = Math.max(58, containerRect.height - popoverRect.height - verticalPadding);
664
+ const left = Math.min(maxLeft, Math.max(horizontalPadding, preferredLeft));
665
+ const top = Math.min(maxTop, Math.max(58, preferredTop));
666
+ popover.style.left = `${left}px`;
667
+ popover.style.top = `${top}px`;
668
+
669
+ const textarea = popover.querySelector('textarea');
670
+ const submitBtn = popover.querySelector('[data-quick-chat-submit]');
671
+ const countEl = popover.querySelector('[data-quick-chat-count]');
672
+ const cancelBtns = popover.querySelectorAll('[data-quick-chat-cancel]');
673
+
674
+ const updateSubmitState = () => {
675
+ const value = String(textarea?.value || '');
676
+ const length = value.length;
677
+ if (countEl) {
678
+ countEl.textContent = String(length);
679
+ }
680
+ const disabled = length === 0 || length > 1000;
681
+ if (submitBtn) {
682
+ submitBtn.classList.toggle('disabled', disabled);
683
+ submitBtn.disabled = disabled;
684
+ }
685
+ };
686
+
687
+ cancelBtns.forEach((btn) => {
688
+ btn.addEventListener('click', () => closeQuickChatPopover());
689
+ });
690
+ submitBtn?.addEventListener('click', async () => {
691
+ const question = String(textarea?.value || '').trim();
692
+ if (!question) return;
693
+ closeQuickChatPopover();
694
+ const payloadContent = composeQuickChatContent(nodeId, question);
695
+ const displayContent = `【快捷】${question}`;
696
+ await sendSandboxChatMessage(question, { payloadContent, displayContent });
697
+ });
698
+ textarea?.addEventListener('input', updateSubmitState);
699
+ textarea?.addEventListener('keydown', async (event) => {
700
+ if (event.key === 'Escape') {
701
+ event.preventDefault();
702
+ closeQuickChatPopover();
703
+ return;
704
+ }
705
+ const isSubmit = event.key === 'Enter' && (event.ctrlKey || event.metaKey);
706
+ if (!isSubmit) return;
707
+ event.preventDefault();
708
+ if (submitBtn instanceof HTMLButtonElement && !submitBtn.disabled) {
709
+ submitBtn.click();
710
+ }
711
+ });
712
+ textarea?.focus();
713
+ updateSubmitState();
714
+ }
715
+
132
716
  async function loadSandboxes() {
133
717
  state.sandboxes = await apiRequest(`${API_BASE}/sandboxes`);
134
718
  renderSandboxes();
@@ -172,14 +756,23 @@ function renderSandboxes() {
172
756
  }
173
757
 
174
758
  async function loadSandbox(id) {
175
- const [sandbox, items, diaries] = await Promise.all([
759
+ closeQuickChatPopover();
760
+ const [sandbox, items, diaries, entities, entityStats] = await Promise.all([
176
761
  apiRequest(`${API_BASE}/sandboxes/${id}`),
177
762
  apiRequest(`${API_BASE}/sandboxes/${id}/items`),
178
- apiRequest(`${API_BASE}/diaries?sandbox_id=${id}`)
763
+ apiRequest(`${API_BASE}/diaries?sandbox_id=${id}`),
764
+ apiRequest(`${API_BASE}/sandboxes/${id}/entities`),
765
+ apiRequest(`${API_BASE}/sandboxes/${id}/entities/stats`),
179
766
  ]);
180
767
  state.currentSandbox = { ...sandbox, items, diaries };
768
+ state.nodeEntities = entities;
769
+ state.nodeEntityStats = entityStats;
181
770
 
182
771
  document.getElementById('sandbox-title').textContent = sandbox.name;
772
+ applyWorkTreeViewMode(state.workTreeViewMode || 'full');
773
+ applyWorkItemAssigneeToggle();
774
+ applySandboxLayoutMode();
775
+ applySandboxFullscreenState();
183
776
  renderSandboxOverview();
184
777
  renderWorkTree();
185
778
  loadSandboxChats(id);
@@ -195,11 +788,14 @@ function renderSandboxOverview() {
195
788
  inProgress: items.filter((i) => i.status === 'in_progress').length,
196
789
  done: items.filter((i) => i.status === 'done').length,
197
790
  };
791
+ const entityStats = state.nodeEntityStats || { issue: {}, knowledge: {}, capability: {} };
198
792
  container.innerHTML = `
199
793
  <div class="summary-card"><div class="label">任务总数</div><div class="value">${items.length}</div></div>
200
794
  <div class="summary-card"><div class="label">待处理/进行中</div><div class="value">${byStatus.pending}/${byStatus.inProgress}</div></div>
201
795
  <div class="summary-card"><div class="label">已完成</div><div class="value">${byStatus.done}</div></div>
202
796
  <div class="summary-card"><div class="label">关联日记</div><div class="value">${diaries.length}</div></div>
797
+ <div class="summary-card"><div class="label">Issue(Open)</div><div class="value">${entityStats.issue?.total || 0}(${(entityStats.issue?.open || 0) + (entityStats.issue?.in_progress || 0) + (entityStats.issue?.blocked || 0)})</div></div>
798
+ <div class="summary-card"><div class="label">Knowledge / Capability</div><div class="value">${entityStats.knowledge?.total || 0} / ${entityStats.capability?.total || 0}</div></div>
203
799
  `;
204
800
  }
205
801
 
@@ -252,6 +848,10 @@ function renderAIActionMessage(action) {
252
848
  const status = getExecutionStatus(action);
253
849
  const statusIcon = status === 'success' ? '✅' : status === 'partial' ? '⚠️' : '❌';
254
850
  const statusClass = status === 'success' ? 'is-success' : status === 'partial' ? 'is-partial' : 'is-failed';
851
+ const details = Array.isArray(action?.execution_result?.details) ? action.execution_result.details : [];
852
+ const detailsHtml = details.length
853
+ ? `<div class="result-detail-list">${details.map((line) => `<div class="result-detail-item">${escapeHtml(String(line))}</div>`).join('')}</div>`
854
+ : '';
255
855
  const undoBtn = action.operationId
256
856
  ? `<button class="btn btn-sm btn-undo" data-operation-id="${action.operationId}" onclick="undoOperation('${action.operationId}', this)">撤销</button>`
257
857
  : '';
@@ -261,6 +861,7 @@ function renderAIActionMessage(action) {
261
861
  <span class="result-text">${escapeHtml(action.observation || '操作完成')}</span>
262
862
  ${undoBtn}
263
863
  </div>
864
+ ${detailsHtml}
264
865
  </div>`;
265
866
  }
266
867
 
@@ -397,6 +998,10 @@ function isKeyOperation(op, before, after) {
397
998
  function formatDiffLabel(key) {
398
999
  const labels = {
399
1000
  name: '名称',
1001
+ title: '标题',
1002
+ entity_type: '类型',
1003
+ work_item_id: '节点ID',
1004
+ content_md: '内容',
400
1005
  status: '状态',
401
1006
  priority: '优先级',
402
1007
  assignee: '负责人',
@@ -406,8 +1011,29 @@ function formatDiffLabel(key) {
406
1011
  return labels[key] || key;
407
1012
  }
408
1013
 
409
- function buildUpdateDiffLines(before, after) {
410
- const trackedFields = ['name', 'status', 'priority', 'assignee', 'parent_id', 'description'];
1014
+ function getOperationTarget(before, after) {
1015
+ const recordType = String(after?.record_type || before?.record_type || 'work_item');
1016
+ if (recordType === 'node_entity') {
1017
+ const entityType = String(after?.entity_type || before?.entity_type || 'entity');
1018
+ return {
1019
+ recordType,
1020
+ typeLabel: entityType.toUpperCase(),
1021
+ name: String(after?.title || before?.title || '-'),
1022
+ scopeLabel: `节点 ${String(after?.work_item_id || before?.work_item_id || '-')}`,
1023
+ };
1024
+ }
1025
+ return {
1026
+ recordType: 'work_item',
1027
+ typeLabel: 'WORK_ITEM',
1028
+ name: String(after?.name || before?.name || '-'),
1029
+ scopeLabel: `父节点 ${String(after?.parent_id || before?.parent_id || 'root')}`,
1030
+ };
1031
+ }
1032
+
1033
+ function buildUpdateDiffLines(before, after, recordType = 'work_item') {
1034
+ const trackedFields = recordType === 'node_entity'
1035
+ ? ['title', 'status', 'priority', 'assignee', 'content_md', 'work_item_id']
1036
+ : ['name', 'status', 'priority', 'assignee', 'parent_id', 'description'];
411
1037
  return trackedFields
412
1038
  .filter((key) => (before?.[key] ?? '') !== (after?.[key] ?? ''))
413
1039
  .map((key) => {
@@ -466,8 +1092,8 @@ function generateWeeklySummaryMarkdown(mode = 'management') {
466
1092
  const sandboxName = state.sandboxes.find((s) => s.id === op.sandbox_id)?.name || op.sandbox_id;
467
1093
  const before = parseOpJson(op.data_before);
468
1094
  const after = parseOpJson(op.data_after);
469
- const taskName = after?.name || before?.name || '-';
470
- lines.push(`- ${new Date(op.created_at).toLocaleString()} [${sandboxName}] ${op.operation_type} ${taskName}`);
1095
+ const target = getOperationTarget(before, after);
1096
+ lines.push(`- ${new Date(op.created_at).toLocaleString()} [${sandboxName}] ${op.operation_type} ${target.typeLabel} ${target.name}`);
471
1097
  }
472
1098
  } else {
473
1099
  lines.push('');
@@ -480,9 +1106,9 @@ function generateWeeklySummaryMarkdown(mode = 'management') {
480
1106
  const sandboxName = state.sandboxes.find((s) => s.id === op.sandbox_id)?.name || op.sandbox_id;
481
1107
  const before = parseOpJson(op.data_before);
482
1108
  const after = parseOpJson(op.data_after);
483
- const taskName = after?.name || before?.name || '-';
484
- const diffLines = op.operation_type === 'update' ? buildUpdateDiffLines(before, after) : [];
485
- lines.push(`- ${new Date(op.created_at).toLocaleString()} [${sandboxName}] ${op.operation_type} ${taskName}`);
1109
+ const target = getOperationTarget(before, after);
1110
+ const diffLines = op.operation_type === 'update' ? buildUpdateDiffLines(before, after, target.recordType) : [];
1111
+ lines.push(`- ${new Date(op.created_at).toLocaleString()} [${sandboxName}] ${op.operation_type} ${target.typeLabel} ${target.name}`);
486
1112
  if (diffLines.length > 0) {
487
1113
  for (const line of diffLines.slice(0, 3)) {
488
1114
  lines.push(` - ${line}`);
@@ -514,26 +1140,28 @@ function renderChanges() {
514
1140
  const items = ops.map((op) => {
515
1141
  const before = parseOpJson(op.data_before);
516
1142
  const after = parseOpJson(op.data_after);
1143
+ const target = getOperationTarget(before, after);
517
1144
  const sandboxName = state.sandboxes.find((s) => s.id === op.sandbox_id)?.name || op.sandbox_id;
518
1145
  let detail = '';
519
1146
  let diffLinesHtml = '';
520
1147
 
521
1148
  if (op.operation_type === 'create') {
522
- detail = `创建任务:${safeText(after?.name || '-')}`;
1149
+ detail = `创建${safeText(target.typeLabel)}:${safeText(target.name)}`;
523
1150
  } else if (op.operation_type === 'update') {
524
- detail = `更新任务:${safeText(after?.name || before?.name || '-')}`;
525
- const diffLines = buildUpdateDiffLines(before, after);
1151
+ detail = `更新${safeText(target.typeLabel)}:${safeText(target.name)}`;
1152
+ const diffLines = buildUpdateDiffLines(before, after, target.recordType);
526
1153
  if (diffLines.length > 0) {
527
1154
  diffLinesHtml = `<div class="change-diff-list">${diffLines.map((line) => `<div class="change-diff-line">${line}</div>`).join('')}</div>`;
528
1155
  }
529
1156
  } else if (op.operation_type === 'delete') {
530
- detail = `删除任务:${safeText(before?.name || '-')}`;
1157
+ detail = `删除${safeText(target.typeLabel)}:${safeText(target.name)}`;
531
1158
  }
532
1159
  return `
533
1160
  <div class="change-item">
534
1161
  <div class="change-meta">
535
1162
  <span class="change-type ${safeText(op.operation_type)}">${safeText(op.operation_type)}</span>
536
1163
  <span>${safeText(sandboxName)}</span>
1164
+ <span class="change-target">${safeText(target.scopeLabel)}</span>
537
1165
  <span>${new Date(op.created_at).toLocaleTimeString()}</span>
538
1166
  </div>
539
1167
  <div class="change-detail">${detail}</div>
@@ -608,6 +1236,7 @@ function editWorkItem(id) {
608
1236
 
609
1237
  function initApp() {
610
1238
  const hash = window.location.hash;
1239
+ loadWorkItemAssigneePreference();
611
1240
 
612
1241
  document.querySelectorAll('.nav-list a').forEach(link => {
613
1242
  link.addEventListener('click', (e) => {
@@ -658,10 +1287,154 @@ function initApp() {
658
1287
  document.getElementById('new-item-parent').value = '';
659
1288
  document.getElementById('item-dialog').showModal();
660
1289
  });
1290
+
1291
+ document.getElementById('toggle-sandbox-present-btn')?.addEventListener('click', () => {
1292
+ state.sandboxPresentationMode = !state.sandboxPresentationMode;
1293
+ applySandboxLayoutMode();
1294
+ });
1295
+
1296
+ document.getElementById('toggle-sandbox-fullscreen-btn')?.addEventListener('click', async () => {
1297
+ await toggleSandboxFullscreen();
1298
+ applySandboxFullscreenState();
1299
+ });
1300
+
1301
+ document.addEventListener('fullscreenchange', () => {
1302
+ applySandboxFullscreenState();
1303
+ });
661
1304
 
662
1305
  document.getElementById('cancel-item-btn')?.addEventListener('click', () => {
663
1306
  document.getElementById('item-dialog').close();
664
1307
  });
1308
+
1309
+ document.getElementById('close-node-drawer-btn')?.addEventListener('click', () => {
1310
+ closeNodeEntityDrawer();
1311
+ });
1312
+
1313
+ document.getElementById('toggle-node-entity-form-btn')?.addEventListener('click', () => {
1314
+ setNodeEntityFormExpanded(!state.nodeEntityFormExpanded);
1315
+ if (state.nodeEntityFormExpanded) {
1316
+ document.getElementById('entity-title-input')?.focus();
1317
+ }
1318
+ });
1319
+
1320
+ document.getElementById('entity-type-select')?.addEventListener('change', () => {
1321
+ applyEntityFormMode();
1322
+ });
1323
+
1324
+ document.getElementById('entity-filter-tabs')?.addEventListener('click', (event) => {
1325
+ const target = event.target;
1326
+ if (!(target instanceof HTMLElement)) return;
1327
+ const btn = target.closest('[data-entity-filter]');
1328
+ if (!(btn instanceof HTMLElement)) return;
1329
+ const filter = btn.getAttribute('data-entity-filter') || 'all';
1330
+ state.nodeEntityFilter = filter;
1331
+ renderNodeEntityFilterTabs();
1332
+ if (state.selectedNodeId) {
1333
+ renderNodeEntityList(state.selectedNodeId);
1334
+ }
1335
+ });
1336
+
1337
+ document.getElementById('cancel-edit-entity-btn')?.addEventListener('click', () => {
1338
+ resetNodeEntityForm();
1339
+ });
1340
+
1341
+ document.getElementById('create-node-entity-btn')?.addEventListener('click', async () => {
1342
+ if (!state.currentSandbox || !state.selectedNodeId) return;
1343
+ const btn = document.getElementById('create-node-entity-btn');
1344
+ const title = document.getElementById('entity-title-input').value.trim();
1345
+ const entity_type = document.getElementById('entity-type-select').value;
1346
+ if (!title) return;
1347
+ const rawStatus = document.getElementById('entity-status-select').value || '';
1348
+ const status = rawStatus || (entity_type === 'issue' ? 'open' : entity_type === 'capability' ? 'building' : '');
1349
+ const payload = {
1350
+ work_item_id: state.selectedNodeId,
1351
+ entity_type,
1352
+ title,
1353
+ content_md: document.getElementById('entity-content-input').value || '',
1354
+ assignee: document.getElementById('entity-assignee-input').value || '',
1355
+ status,
1356
+ priority: document.getElementById('entity-priority-select').value || '',
1357
+ capability_type: document.getElementById('entity-capability-type-input').value || '',
1358
+ };
1359
+
1360
+ const isEditing = Boolean(state.editingNodeEntityId);
1361
+ setButtonState(btn, { disabled: true, text: isEditing ? '保存中...' : '添加中...' });
1362
+ try {
1363
+ if (isEditing) {
1364
+ await apiRequest(`${API_BASE}/sandboxes/${state.currentSandbox.id}/entities/${state.editingNodeEntityId}`, {
1365
+ method: 'PUT',
1366
+ body: JSON.stringify(payload),
1367
+ });
1368
+ } else {
1369
+ await apiRequest(`${API_BASE}/sandboxes/${state.currentSandbox.id}/entities`, {
1370
+ method: 'POST',
1371
+ body: JSON.stringify(payload),
1372
+ });
1373
+ }
1374
+ await loadSandbox(state.currentSandbox.id);
1375
+ showNodeEntityDrawer(state.selectedNodeId);
1376
+ resetNodeEntityForm();
1377
+ } finally {
1378
+ const defaultText = state.editingNodeEntityId ? '保存修改' : '添加记录';
1379
+ setButtonState(btn, { disabled: false, text: defaultText });
1380
+ }
1381
+ });
1382
+
1383
+ const entitySubmitByShortcut = async (event) => {
1384
+ const isSubmit = event.key === 'Enter' && (event.ctrlKey || event.metaKey);
1385
+ if (!isSubmit) return;
1386
+ event.preventDefault();
1387
+ const drawer = document.getElementById('node-entity-drawer');
1388
+ const form = document.getElementById('drawer-create-form');
1389
+ const submitBtn = document.getElementById('create-node-entity-btn');
1390
+ if (drawer?.classList.contains('hidden')) return;
1391
+ if (form?.classList.contains('hidden')) return;
1392
+ if (!(submitBtn instanceof HTMLButtonElement) || submitBtn.disabled) return;
1393
+ submitBtn.click();
1394
+ };
1395
+ document.getElementById('entity-title-input')?.addEventListener('keydown', entitySubmitByShortcut);
1396
+ document.getElementById('entity-content-input')?.addEventListener('keydown', entitySubmitByShortcut);
1397
+
1398
+ document.addEventListener('keydown', (event) => {
1399
+ if (event.key !== 'Escape') return;
1400
+ if (quickChatPopover) {
1401
+ event.preventDefault();
1402
+ event.stopPropagation();
1403
+ closeQuickChatPopover();
1404
+ return;
1405
+ }
1406
+ const drawer = document.getElementById('node-entity-drawer');
1407
+ if (drawer && !drawer.classList.contains('hidden')) {
1408
+ event.preventDefault();
1409
+ event.stopPropagation();
1410
+ closeNodeEntityDrawer();
1411
+ return;
1412
+ }
1413
+ if (state.sandboxFullscreenMode) {
1414
+ event.preventDefault();
1415
+ event.stopPropagation();
1416
+ }
1417
+ });
1418
+
1419
+ document.addEventListener('click', (event) => {
1420
+ const drawer = document.getElementById('node-entity-drawer');
1421
+ const workTree = document.getElementById('work-tree');
1422
+ if (!drawer || drawer.classList.contains('hidden')) return;
1423
+ const target = event.target;
1424
+ if (!(target instanceof Node)) return;
1425
+ if (drawer.contains(target)) return;
1426
+ if (workTree?.contains(target)) return;
1427
+ closeNodeEntityDrawer();
1428
+ });
1429
+
1430
+ document.addEventListener('click', (event) => {
1431
+ if (!quickChatPopover) return;
1432
+ const target = event.target;
1433
+ if (!(target instanceof Node)) return;
1434
+ if (quickChatPopover.contains(target)) return;
1435
+ if (target instanceof HTMLElement && target.closest('[data-action="quick-chat"]')) return;
1436
+ closeQuickChatPopover();
1437
+ });
665
1438
 
666
1439
  document.getElementById('confirm-item-btn')?.addEventListener('click', async () => {
667
1440
  const dialog = document.getElementById('item-dialog');
@@ -757,48 +1530,8 @@ function initApp() {
757
1530
  const input = document.getElementById('sandbox-chat-input');
758
1531
  const content = input.value.trim();
759
1532
  if (!content || !state.currentSandbox) return;
760
-
761
- const btn = document.getElementById('sandbox-send-btn');
762
- const messages = document.getElementById('sandbox-chat-messages');
763
-
764
- messages.insertAdjacentHTML('beforeend', `<div class="chat-message user">${escapeHtml(content)}</div>`);
765
- const loadingMessage = appendLoadingMessage(messages);
766
- messages.scrollTop = messages.scrollHeight;
767
1533
  input.value = '';
768
-
769
- const originalText = btn.textContent;
770
- setButtonState(btn, { disabled: true, text: '思考中' });
771
-
772
- try {
773
- const history = state.chats
774
- .filter(c => c.role)
775
- .slice(-10)
776
- .map(c => ({ role: c.role, content: typeof c.content === 'string' ? c.content : JSON.stringify(c.content) }));
777
-
778
- const result = await apiRequest(`${API_BASE}/chats/react`, {
779
- method: 'POST',
780
- body: JSON.stringify({
781
- content,
782
- sandbox_id: state.currentSandbox.id,
783
- history,
784
- pending_action: state.pendingAction
785
- }),
786
- });
787
-
788
- console.log('Chat result:', result);
789
-
790
- loadingMessage?.remove();
791
-
792
- await handleAIAction(result.action, state.currentSandbox.id);
793
- await loadSandbox(state.currentSandbox.id);
794
- } catch (error) {
795
- console.error('发送消息失败:', error);
796
- loadingMessage?.remove();
797
- messages.insertAdjacentHTML('beforeend', `<div class="chat-message assistant error">❌ 错误: ${escapeHtml(error.message)}</div>`);
798
- messages.scrollTop = messages.scrollHeight;
799
- } finally {
800
- setButtonState(btn, { disabled: false, text: originalText || '发送' });
801
- }
1534
+ await sendSandboxChatMessage(content);
802
1535
  });
803
1536
 
804
1537
  async function handleAIAction(action, sandboxId) {
@@ -907,6 +1640,8 @@ function initApp() {
907
1640
  state.pendingAction = null;
908
1641
  }
909
1642
  }
1643
+
1644
+ sandboxActionHandler = handleAIAction;
910
1645
 
911
1646
  document.getElementById('sandbox-chat-input')?.addEventListener('keypress', (e) => {
912
1647
  if (e.key === 'Enter') {
@@ -962,6 +1697,18 @@ function initApp() {
962
1697
  renderWorkTree();
963
1698
  });
964
1699
 
1700
+ document.getElementById('work-tree-view-mode')?.addEventListener('change', (e) => {
1701
+ const nextMode = e.target.value || 'full';
1702
+ applyWorkTreeViewMode(nextMode);
1703
+ renderWorkTree();
1704
+ });
1705
+
1706
+ document.getElementById('work-item-show-assignee-toggle')?.addEventListener('change', (e) => {
1707
+ state.workItemShowAssignee = Boolean(e.target.checked);
1708
+ persistWorkItemAssigneePreference();
1709
+ renderWorkTree();
1710
+ });
1711
+
965
1712
  document.getElementById('diary-search')?.addEventListener('input', (e) => {
966
1713
  state.diarySearch = e.target.value || '';
967
1714
  renderDiaries();
@@ -1049,12 +1796,26 @@ function initApp() {
1049
1796
  await navigator.clipboard.writeText(content);
1050
1797
  alert('周报摘要已复制');
1051
1798
  });
1799
+
1800
+ window.addEventListener('resize', () => {
1801
+ if (!state.currentSandbox) return;
1802
+ if (resizeRenderTimer) {
1803
+ clearTimeout(resizeRenderTimer);
1804
+ }
1805
+ resizeRenderTimer = setTimeout(() => {
1806
+ renderWorkTree();
1807
+ }, 120);
1808
+ });
1052
1809
 
1053
1810
  window.addEventListener('hashchange', handleRoute);
1054
1811
  handleRoute();
1055
1812
 
1056
1813
  async function handleRoute() {
1057
1814
  const hash = window.location.hash.slice(1) || '/';
1815
+ const fullscreenRoot = getSandboxFullscreenElement();
1816
+ if (!hash.startsWith('/sandbox/') && fullscreenRoot && document.fullscreenElement === fullscreenRoot) {
1817
+ await document.exitFullscreen();
1818
+ }
1058
1819
 
1059
1820
  if (hash === '/') {
1060
1821
  showPage('home');