From fbd34334a54aff9dcd8377155ff92a57279e833b Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Fri, 5 Jun 2026 14:41:48 +0900 Subject: [PATCH] Calendar overnight-event rendering + clickable [View note] link from chat - Calendar overnight events render proportionally across day boundaries via --start-frac / --end-frac CSS vars instead of bleeding as full-day on day 2. - Recurring-event delete strips the master uid + all master::* sibling instances optimistically so the row clears immediately instead of waiting for the next sync re-render. - manage_notes(create) now returns note_id + open_url, and agent_loop appends a markdown [View note](#note-) link mirroring the deep-research pattern. - chatRenderer's hash-link router (already wired for #note-id) reaches the new notes.openNote(id) helper, which force-closes/reopens the Notes panel, polls for the target card, and runs a brief outline flash so the user can locate it on long lists. --- src/agent_loop.py | 13 +++++ src/tool_implementations.py | 14 ++++- static/js/calendar.js | 102 ++++++++++++++++++++++++++++++++++-- static/js/notes.js | 49 ++++++++++++++++- 4 files changed, 171 insertions(+), 7 deletions(-) diff --git a/src/agent_loop.py b/src/agent_loop.py index 401e7bb..6bd9ba8 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -2348,6 +2348,19 @@ async def stream_agent_loop( _anchor = f"\n\n[Open in Deep Research](#research-{_rsid})\n" yield 'data: ' + json.dumps({"delta": _anchor}) + '\n\n' + # Same pattern for notes: when manage_notes creates a note + # and returns note_id, drop a `[View note](#note-)` link + # into the stream so chatRenderer's click handler routes to + # the new openNote() in notes.js — opens the notes panel and + # scrolls/flashes the matching card. Without this, the agent + # would write "View note" as a phrase with no target. + _nid = result.get("note_id") + if _nid and block.tool_type == "manage_notes": + _title = (result.get("note_title") or "").strip() + _label = f"View note: {_title}" if _title else "View note" + _anchor = f"\n\n[{_label}](#note-{_nid})\n" + yield 'data: ' + json.dumps({"delta": _anchor}) + '\n\n' + # Save for history persistence tool_event = { "round": round_num, diff --git a/src/tool_implementations.py b/src/tool_implementations.py index c46a10c..dbaf50c 100644 --- a/src/tool_implementations.py +++ b/src/tool_implementations.py @@ -1957,7 +1957,19 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict: ) db.add(note) db.commit() - return {"response": f"Note created: \"{title or '(untitled)'}\" (id: {note.id[:8]})", "exit_code": 0} + # Return note_id so the chat-side renderer can build a real + # "View note" button that opens the notes modal at this id. + # Previously the create response only included a prose + # confirmation; the model would type "View note" as a markdown + # link with no target, leaving the user with a click that + # did nothing and uncertainty about whether the note was made. + return { + "response": f"Note created: \"{title or '(untitled)'}\" (id: {note.id[:8]})", + "note_id": note.id, + "note_title": title or "", + "open_url": f"/#open=notes¬e={note.id}", + "exit_code": 0, + } elif action == "update": note_id = args.get("id", "") diff --git a/static/js/calendar.js b/static/js/calendar.js index ebd6bfc..4112cad 100644 --- a/static/js/calendar.js +++ b/static/js/calendar.js @@ -299,8 +299,31 @@ async function _updateEvent(uid, data) { } async function _deleteEvent(uid) { - const backup = _allEvents[uid]; - delete _allEvents[uid]; + // Multiple "sibling" UIDs may need to vanish optimistically: + // 1. The exact uid the user clicked. + // 2. If the user clicked a RECURRING occurrence (uid contains "::"), + // the server deletes the master + every occurrence — so we strip + // the master uid AND every "master::*" expansion from the + // client-side caches too. Without this, deleting one day of a + // multi-day recurring task only removed THAT day visually; the + // other days kept rendering until the next full refresh. + // 3. If the user clicked the master, strip every "master::*" + // expansion (same prefix scan). + const masterUid = uid.includes('::') ? uid.split('::')[0] : uid; + const backups = {}; + const _matches = (k) => k === uid || k === masterUid || k.startsWith(masterUid + '::'); + + for (const k of Object.keys(_allEvents)) { + if (_matches(k)) { + backups[k] = _allEvents[k]; + delete _allEvents[k]; + } + } + if (Array.isArray(_events)) { + _events = _events.filter(e => !(e && _matches(e.uid || ''))); + } + if (_open) _render(); + _updateBadge && _updateBadge(); const isRecurring = uid.includes('::'); fetch(`${API_BASE}/api/calendar/events/${encodeURIComponent(uid)}`, { method: 'DELETE', credentials: 'same-origin', @@ -313,7 +336,11 @@ async function _deleteEvent(uid) { _saveCache && _saveCache(); } }).catch((e) => { - if (backup) _allEvents[uid] = backup; + // Server rejected — restore every uid we optimistically stripped. + for (const [k, ev] of Object.entries(backups)) { + _allEvents[k] = ev; + if (Array.isArray(_events)) _events.push(ev); + } if (window.uiModule) window.uiModule.showError('Failed to delete event: ' + (e?.message || 'unknown')); if (_open) _render(); }); @@ -980,7 +1007,39 @@ async function _renderMonth() { const startColInt = Math.round(startCol); const endColInt = Math.round(endCol); const span = endColInt - startColInt + 1; - h += `
${_e(md.summary)}
`; + // Proportional offsets for timed events that span across midnight + // (e.g. 8 PM Mon → 5 AM Tue). Without this, an overnight serve + // window visually fills the ENTIRE next day even when it only + // covers a few hours. All-day events keep the full-day shape. + // Bar visually spans from column (col+startFrac) to (col+span-1+endFrac), + // so a 8 PM→5 AM run shows ~17% of day 1 + ~21% of day 2, not 200%. + let startFrac = 0; + let endFrac = 1; + if (!md.all_day) { + try { + const sIso = md.dtstart || ''; + const eIso = md.dtend || ''; + const sDate = sIso ? new Date(sIso) : null; + const eDate = eIso ? new Date(eIso) : null; + // First-visible-day fraction (0 = midnight start). Clamp to 0 + // when the event started before this row, so the bar still + // starts at the row's left edge. + if (sDate && !isNaN(sDate) && mdStart >= rowStart) { + const midnight = new Date(sDate); midnight.setHours(0, 0, 0, 0); + startFrac = Math.max(0, Math.min(1, (sDate - midnight) / 86400000)); + } + if (eDate && !isNaN(eDate) && mdEnd <= rowEnd) { + const midnight = new Date(eDate); midnight.setHours(0, 0, 0, 0); + endFrac = Math.max(0, Math.min(1, (eDate - midnight) / 86400000)); + // CalDAV end-times are exclusive: an event ending at exactly + // 00:00 on day N really ended at end-of-day N-1, so endFrac=0 + // would visually paint a zero-width slice. Snap to a small + // visible minimum (5% of a day) so the bar still registers. + if (endFrac === 0) endFrac = 1; + } + } catch (_) { startFrac = 0; endFrac = 1; } + } + h += `
${_e(md.summary)}
`; barSlot++; } h += ''; @@ -2688,6 +2747,28 @@ function _showEventForm(existing, defaultDate, defaultEndDate) { + ${(() => { + // Cookbook-task back-link. When the description carries a + // "cookbook_task_id: " marker (set by cookbookSchedule.js + // when the user ticks "Create event in calendar"), render an + // Open-task button so the user can jump straight to the + // source task in the Tasks tab. + const _ct = (existing?.description || '').match(/cookbook_task_id:\s*([A-Za-z0-9_-]+)/); + if (!_ct) return ''; + return ``; + })()}