diff --git a/lib/project-connection.js b/lib/project-connection.js index bbb47bde..cc1211ef 100644 --- a/lib/project-connection.js +++ b/lib/project-connection.js @@ -301,7 +301,7 @@ function attachConnection(ctx) { } sendTo(ws, { type: "history_meta", total: total, from: fromIndex }); for (var i = fromIndex; i < total; i++) { - sendTo(ws, hydrateImageRefs(active.history[i])); + sendTo(ws, Object.assign({}, hydrateImageRefs(active.history[i]), { _historyIndex: i })); } var _lastUsage = null, _lastModelUsage = null, _lastCost = null, _lastStreamInputTokens = null; for (var _ri = total - 1; _ri >= 0; _ri--) { diff --git a/lib/project-sessions.js b/lib/project-sessions.js index cf5b92d3..592cb4d3 100644 --- a/lib/project-sessions.js +++ b/lib/project-sessions.js @@ -588,7 +588,9 @@ function attachSessions(ctx) { var targetFrom = typeof msg.target === "number" ? msg.target : before - sm.HISTORY_PAGE_SIZE; var from = sm.findTurnBoundary(session.history, Math.max(0, targetFrom)); var to = before; - var items = session.history.slice(from, to).map(hydrateImageRefs); + var items = session.history.slice(from, to).map(function (item, index) { + return Object.assign({}, hydrateImageRefs(item), { _historyIndex: from + index }); + }); sendTo(ws, { type: "history_prepend", items: items, diff --git a/lib/public/app.js b/lib/public/app.js index 383d9265..568aba94 100644 --- a/lib/public/app.js +++ b/lib/public/app.js @@ -315,6 +315,8 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes historyTotal: 0, replayingHistory: false, historyReplayBatch: null, + pendingSessionEntryNavigation: null, + sessionNavigationRequestId: null, projectName: projectName, cwd: "", currentSlug: currentSlug, diff --git a/lib/public/css/command-palette.css b/lib/public/css/command-palette.css index 2a86c5f1..77b1dc6f 100644 --- a/lib/public/css/command-palette.css +++ b/lib/public/css/command-palette.css @@ -550,41 +550,20 @@ body.mate-dm-active .search-clay-transcript .search-clay-message-user .bubble, .clayos-issue-link { --record-color: var(--link, var(--accent)); } .clayos-log-link { --record-color: var(--accent); } -.clayos-session-link { - display: inline-flex; - align-items: center; - gap: 6px; - min-height: 26px; - margin: 2px 1px; - padding: 3px 8px 3px 6px; - border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border)); - border-radius: 7px; - background: color-mix(in srgb, var(--accent) 7%, var(--bg-alt)); - color: var(--text); - font: inherit; - font-size: 11px; - line-height: 1; - vertical-align: middle; - cursor: pointer; +.clayos-session-link { --record-color: var(--accent); } + +#messages .session-entry-target { + outline: 2px solid color-mix(in srgb, var(--accent) 72%, transparent); + outline-offset: 3px; + border-radius: 8px; + background: color-mix(in srgb, var(--accent) 7%, transparent); + animation: sessionEntryTargetFade 1800ms ease-out forwards; } -.clayos-session-link::before { - content: "\2197"; - display: grid; - place-items: center; - width: 17px; - height: 17px; - border-radius: 5px; - background: color-mix(in srgb, var(--accent) 14%, transparent); - color: var(--accent); - font-size: 12px; + +@keyframes sessionEntryTargetFade { + 0%, 72% { background: color-mix(in srgb, var(--accent) 10%, transparent); } + 100% { background: transparent; } } -.clayos-session-link > span { font-weight: 620; } -.clayos-session-link > small { color: var(--text-muted); font-size: 10px; } -.clayos-session-link:hover { border-color: color-mix(in srgb, var(--accent) 52%, var(--border)); background: color-mix(in srgb, var(--accent) 11%, var(--bg-alt)); } -.clayos-session-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } -.clayos-session-link.is-loading { opacity: 0.62; cursor: progress; } -.clayos-session-link.is-loading::before { animation: searchClaySpin 1.2s linear infinite; } -.clayos-session-link.is-error { border-color: color-mix(in srgb, var(--warning) 48%, var(--border)); } .search-clay-pending { align-self: flex-start; diff --git a/lib/public/modules/app-header.js b/lib/public/modules/app-header.js index 2cf8f507..678388d1 100644 --- a/lib/public/modules/app-header.js +++ b/lib/public/modules/app-header.js @@ -350,6 +350,7 @@ export function prependOlderHistory(items, meta) { // Save current rendering state var savedMsgEl = store.get('currentMsgEl'); + var savedReplayingHistory = store.get('replayingHistory'); var savedActivity = getActivityEl(); var savedFullText = store.get('currentFullText'); var savedTurnCounter = getTurnCounter(); @@ -359,7 +360,7 @@ export function prependOlderHistory(items, meta) { var savedUsage = JSON.parse(JSON.stringify(getSessionUsage())); // Reset to initial values for clean rendering - store.set({ currentMsgEl: null, currentFullText: "" }); + store.set({ currentMsgEl: null, currentFullText: "", replayingHistory: true }); setActivityEl(null); setTurnCounter(0); resetToolState(); @@ -387,7 +388,7 @@ export function prependOlderHistory(items, meta) { setPrependAnchor(null); // Restore saved state - store.set({ currentMsgEl: savedMsgEl, currentFullText: savedFullText }); + store.set({ currentMsgEl: savedMsgEl, currentFullText: savedFullText, replayingHistory: savedReplayingHistory }); setActivityEl(savedActivity); setTurnCounter(savedTurnCounter); restoreToolState(savedToolsState); diff --git a/lib/public/modules/app-messages.js b/lib/public/modules/app-messages.js index ae163109..80d182d4 100644 --- a/lib/public/modules/app-messages.js +++ b/lib/public/modules/app-messages.js @@ -40,7 +40,7 @@ import { handleHomeMateMemoryState, handleHomeMateKnowledgeState, syncHomeMateSe import { renderKnowledgeList, handleKnowledgeContent, handleKnowledgeMutation } from './mate-knowledge.js'; import { handleKnowledgeWorkbenchMessage } from './mate-knowledge-workbench.js'; import { renderMemoryList } from './mate-memory.js'; -import { handlePaletteSessionSwitch, setPaletteVersion } from './command-palette.js'; +import { handlePaletteSessionSwitch, handlePaletteHistoryDone, handlePaletteHistoryPrepended, setPaletteVersion } from './command-palette.js'; import { handleFindInSessionResults } from './session-search.js'; import { syncPaneTitles, maybeRestoreSplitGroup, openGroup } from './split-view.js'; import { showPairDialog, handlePairCreated, handleSplitDelegation, showWorkerDelegationNotice, hideWorkerDelegationNotice } from './split-pair-ui.js'; @@ -224,6 +224,7 @@ export function processMessage(msg) { } } + store.set({ currentHistoryIndex: Number.isSafeInteger(msg._historyIndex) && msg._historyIndex >= 0 ? msg._historyIndex : null }); switch (msg.type) { case "message_ack": var acknowledgedEntry = acknowledgeMessage(msg.clientMessageId); @@ -250,6 +251,7 @@ export function processMessage(msg) { case "history_prepend": prependOlderHistory(msg.items, msg.meta); + handlePaletteHistoryPrepended(); break; case "history_done": @@ -311,11 +313,16 @@ export function processMessage(msg) { // re-pins on every height change while armed. Disarms early on // any real user scroll input. // Skip arming when we have a pending in-conversation navigate - // target (file-edit deeplink) — the navigate block below scrolls - // that element into view, and sticky-bottom would fight it. + // target (file-edit or Session-entry deeplink) — the navigate block + // below scrolls that element into view, and sticky-bottom would + // fight it. var nav = getPendingNavigate(); var hasNavTarget = nav && (nav.toolId || nav.assistantUuid); - if (hasNavTarget) { + var sessionEntryNav = store.get('pendingSessionEntryNavigation'); + var hasSessionEntryNav = sessionEntryNav + && String(sessionEntryNav.projectSlug) === String(store.get('currentSlug')) + && String(sessionEntryNav.sessionId) === String(store.get('activeSessionId')); + if (hasNavTarget || hasSessionEntryNav) { // Navigate block below will scrollIntoView on the target — don't // arm sticky-bottom or it would fight that scroll. scrollToBottom(); @@ -343,6 +350,7 @@ export function processMessage(msg) { } }); } + requestAnimationFrame(function () { handlePaletteHistoryDone(); }); break; case "restore_mate_dm": diff --git a/lib/public/modules/app-rendering.js b/lib/public/modules/app-rendering.js index a3428d5c..10893475 100644 --- a/lib/public/modules/app-rendering.js +++ b/lib/public/modules/app-rendering.js @@ -8,10 +8,10 @@ import { refreshIcons } from './icons.js'; import { closeToolGroup } from './tools.js'; import { sendMessage, hasSendableContent } from './input.js'; import { getChatLayout } from './theme.js'; -import { addToMessages, scrollToBottom, forceScrollToBottom, getMsgTime, shouldGroupMessage, getTurnCounter, getActivityEl } from './chat-render-runtime.js'; +import { addToMessages, addHistoryIndex, scrollToBottom, forceScrollToBottom, getMsgTime, shouldGroupMessage, getTurnCounter, getActivityEl } from './chat-render-runtime.js'; import { resolveProjectAssistantIdentity, applyProjectAssistantIdentity } from './project-chat-identity.js'; export { VENDOR_ORDER, EXPERIMENTAL_VENDORS, isExperimentalVendor } from './vendor-priority.js'; -export { initRendering, addToMessages, scrollToBottom, forceScrollToBottom, getMsgTime, shouldGroupMessage, getTurnCounter, setTurnCounter, getPrependAnchor, setPrependAnchor, getActivityEl, setActivityEl, getIsUserScrolledUp, setIsUserScrolledUp, getStickyBottom, armStickyBottom, disarmStickyBottom, VENDOR_AVATARS, VENDOR_NAMES, VENDOR_HOMEPAGES } from './chat-render-runtime.js'; +export { initRendering, addToMessages, addHistoryIndex, scrollToBottom, forceScrollToBottom, getMsgTime, shouldGroupMessage, getTurnCounter, setTurnCounter, getPrependAnchor, setPrependAnchor, getActivityEl, setActivityEl, getIsUserScrolledUp, setIsUserScrolledUp, getStickyBottom, armStickyBottom, disarmStickyBottom, VENDOR_AVATARS, VENDOR_NAMES, VENDOR_HOMEPAGES } from './chat-render-runtime.js'; export { addUserMessage, addSystemMessage, addConflictMessage, addContextOverflowMessage } from './app-message-cards.js'; // --- Module-owned state (not in store) --- @@ -44,7 +44,8 @@ export function addCopyHandler(msgEl, rawText) { } export function appendDelta(text) { - ensureAssistantBlock(); + var assistantEl = ensureAssistantBlock(); + if (store.get('replayingHistory')) addHistoryIndex(assistantEl, store.get('currentHistoryIndex')); streamBuffer += text; if (store.get('replayingHistory')) { store.set({ currentFullText: store.get('currentFullText') + streamBuffer }); diff --git a/lib/public/modules/chat-render-runtime.js b/lib/public/modules/chat-render-runtime.js index d97825df..723c9ab4 100644 --- a/lib/public/modules/chat-render-runtime.js +++ b/lib/public/modules/chat-render-runtime.js @@ -155,11 +155,19 @@ export function setIsUserScrolledUp(value) { isUserScrolledUp = value; } export function addToMessages(element) { var messagesEl = getMessagesEl(); + addHistoryIndex(element, store.get('currentHistoryIndex')); if (prependAnchor) messagesEl.insertBefore(element, prependAnchor); else messagesEl.appendChild(element); keepActiveThinkingAtTail(); } +export function addHistoryIndex(element, historyIndex) { + if (!element || !Number.isSafeInteger(historyIndex) || historyIndex < 0) return; + var existing = element.dataset.historyIndexes || ""; + if (existing.split(",").indexOf(String(historyIndex)) !== -1) return; + element.dataset.historyIndexes = existing ? existing + "," + historyIndex : String(historyIndex); +} + // Thinking is a single accumulating record for the active turn. Other live // transcript entries may arrive between reasoning segments, so keep only the // currently active record after the tail activity indicator. diff --git a/lib/public/modules/clay-log-links.js b/lib/public/modules/clay-log-links.js index 11ad0962..61204ae0 100644 --- a/lib/public/modules/clay-log-links.js +++ b/lib/public/modules/clay-log-links.js @@ -1,10 +1,33 @@ import { enhanceClayIssueLinks, isExactClayIssueReference } from './clay-issue-links.js'; import { enhanceGithubLinks } from './github-links.js'; +import { escapeHtml } from './utils.js'; export function renderClayRecordLink(href, text) { + if (isExactClaySessionReference(href)) return renderClaySessionLink(href.trim(), text); if (!isExactClayLogReference(href) && !isExactClayIssueReference(href)) return null; return '' + text + ''; } + +export function isExactClaySessionReference(text) { + return parseClaySessionReference(text) !== null; +} + +export function renderClaySessionLink(ref, text) { + var parsed = parseClaySessionReference(ref); + if (!parsed) return null; + var label = text ? String(text).trim() : ""; + var safeLabel = escapeHtml(label); + var entryAttr = parsed.entryIndex === null ? "" : ' data-session-entry-index="' + parsed.entryIndex + '"'; + return ''; +} + +export function parseClaySessionReference(text) { + var match = /^((?:session:[A-Za-z0-9_-]{24}))(?:#entry-([0-9]+))?$/.exec((text || "").trim()); + if (!match) return null; + var entryIndex = match[2] === undefined ? null : Number(match[2]); + if (entryIndex !== null && (!Number.isSafeInteger(entryIndex) || entryIndex < 0)) return null; + return { ref: match[1], entryIndex: entryIndex }; +} export function parseClayLogReferences(text) { var pattern = /\[clayos\/(log:[A-Za-z0-9_-]{24})(?:\s+[\u2014-]\s+([^\]\n]{1,80}))?\]|(^|[^A-Za-z0-9_/:])(log:[A-Za-z0-9_-]{24})(?![A-Za-z0-9_-])/g; var results = []; diff --git a/lib/public/modules/command-palette.js b/lib/public/modules/command-palette.js index 1ea57d4a..4f5ac12d 100644 --- a/lib/public/modules/command-palette.js +++ b/lib/public/modules/command-palette.js @@ -5,6 +5,8 @@ import { openSearch as openSessionSearch } from './session-search.js'; import { startSearchClayChat, attachSearchClayChat, detachSearchClayChat, getSearchClayChatSummary } from './search-clay-chat.js'; import { getWs } from './ws-ref.js'; import { openProjectLog } from './project-logs.js'; +import { store } from './store.js'; +import { disarmStickyBottom } from './chat-render-runtime.js'; function formatRelativeDate(ts) { if (!ts) return ""; @@ -70,6 +72,10 @@ function handleClaySessionLinkClick(event) { var requestId = "clayos-" + (isLog ? "log-" : "session-") + Date.now() + "-" + Math.random().toString(36).slice(2, 9); if (isLog) link.dataset.logRequest = requestId; else link.dataset.sessionRequest = requestId; + if (!isLog) store.set({ sessionNavigationRequestId: requestId, sessionNavigationRequestContext: { + projectSlug: store.get('currentSlug'), + sessionId: store.get('activeSessionId') + } }); link.classList.remove("is-error"); link.classList.add("is-loading"); var request = { requestId: requestId, surface: link.closest(".search-clay-chat") ? "search" : "home" }; @@ -79,6 +85,7 @@ function handleClaySessionLinkClick(event) { } else { request.type = "home_clay_session_resolve"; request.sessionRef = link.dataset.sessionRef; + if (link.dataset.sessionEntryIndex !== undefined) request.entryIndex = Number(link.dataset.sessionEntryIndex); } ws.send(JSON.stringify(request)); } @@ -91,12 +98,31 @@ export function handleClaySessionTarget(msg) { for (var i = 0; i < links.length; i++) { if ((isLog ? links[i].dataset.logRequest : links[i].dataset.sessionRequest) === msg.requestId) link = links[i]; } + if (!isLog && store.get('sessionNavigationRequestId') !== msg.requestId) { + if (link) { + link.classList.remove("is-loading"); + delete link.dataset.sessionRequest; + } + return true; + } + if (!isLog) { + var requestContext = store.get('sessionNavigationRequestContext'); + if (requestContext && (String(store.get('currentSlug')) !== String(requestContext.projectSlug) || String(store.get('activeSessionId')) !== String(requestContext.sessionId))) { + if (link) { + link.classList.remove("is-loading"); + delete link.dataset.sessionRequest; + } + store.set({ pendingSessionEntryNavigation: null, sessionNavigationRequestContext: null }); + return true; + } + } if (link) { link.classList.remove("is-loading"); if (isLog) delete link.dataset.logRequest; else delete link.dataset.sessionRequest; } if (msg.status !== "ready" || !msg.target) { + if (!isLog) store.set({ pendingSessionEntryNavigation: null }); if (link) { link.classList.add("is-error"); link.title = msg.error || "Item not available"; @@ -108,7 +134,7 @@ export function handleClaySessionTarget(msg) { navigateToLog(msg.target); return true; } - navigateToSession({ projectSlug: msg.target.projectSlug, sessionId: msg.target.sessionId }, null); + navigateToSession({ projectSlug: msg.target.projectSlug, sessionId: msg.target.sessionId }, null, msg.target.entryIndex); return true; } @@ -151,9 +177,61 @@ export function handlePaletteSessionSwitch() { var nav = pendingNav; pendingNav = null; if (ctx.currentSlug && ctx.currentSlug() === nav.slug) { - ctx.selectSession(nav.sessionId); - if (nav.query) setTimeout(function () { openSessionSearch(nav.query); }, 400); + navigateToSession({ projectSlug: nav.slug, sessionId: nav.sessionId }, nav.query, nav.entryIndex); + } +} + +export function handlePaletteHistoryDone() { + var pending = store.get('pendingSessionEntryNavigation'); + if (!pending) return; + if (!isActiveEntryNavigation(pending)) { + store.set({ pendingSessionEntryNavigation: null }); + return; + } + var historyFrom = store.get('historyFrom') || 0; + if (pending.entryIndex < historyFrom) { + var ws = getWs(); + if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: "load_more_history", before: historyFrom, target: pending.entryIndex })); + return; } + jumpToSessionEntry(pending.entryIndex); +} + +export function handlePaletteHistoryPrepended() { + var pending = store.get('pendingSessionEntryNavigation'); + if (!pending) return; + if (!isActiveEntryNavigation(pending)) { + store.set({ pendingSessionEntryNavigation: null }); + return; + } + requestAnimationFrame(function () { + if (store.get('pendingSessionEntryNavigation') === pending) jumpToSessionEntry(pending.entryIndex, pending); + }); +} + +function isActiveEntryNavigation(pending) { + return String(store.get('currentSlug') || (ctx && ctx.currentSlug ? ctx.currentSlug() : "")) === String(pending.projectSlug) && String(store.get('activeSessionId')) === String(pending.sessionId); +} + +function jumpToSessionEntry(entryIndex, pending) { + if (pending && !isActiveEntryNavigation(pending)) return; + var messages = document.querySelectorAll("#messages [data-history-index], #messages [data-history-indexes]"); + var target = null; + for (var i = 0; i < messages.length; i++) { + var indexes = (messages[i].dataset.historyIndexes || messages[i].dataset.historyIndex || "").split(","); + if (indexes.indexOf(String(entryIndex)) !== -1) { target = messages[i]; break; } + } + store.set({ pendingSessionEntryNavigation: null }); + if (!target) return; + disarmStickyBottom(); + var oldTargets = document.querySelectorAll("#messages .session-entry-target"); + for (var oldIndex = 0; oldIndex < oldTargets.length; oldIndex++) oldTargets[oldIndex].classList.remove("session-entry-target"); + var reducedMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + target.scrollIntoView({ behavior: reducedMotion ? "auto" : "smooth", block: "center" }); + target.classList.remove("session-entry-target"); + void target.offsetWidth; + target.classList.add("session-entry-target"); + setTimeout(function () { target.classList.remove("session-entry-target"); }, reducedMotion ? 700 : 1800); } function navigateToLog(target) { @@ -357,13 +435,24 @@ function activateItem(entry) { if (entry.type === "mate-session" || entry.type === "session") navigateToSession(entry.data, entry.query); } -function navigateToSession(item, query) { +function navigateToSession(item, query, entryIndex) { closeCommandPalette(); + pendingNav = null; + var hasEntry = entryIndex !== null && entryIndex !== undefined && Number.isSafeInteger(Number(entryIndex)) && Number(entryIndex) >= 0; + store.set({ pendingSessionEntryNavigation: hasEntry ? { projectSlug: item.projectSlug, sessionId: item.sessionId, entryIndex: Number(entryIndex) } : null }); if (ctx.currentSlug && ctx.currentSlug() === item.projectSlug) { + var alreadyActive = String(store.get('activeSessionId')) === String(item.sessionId); + if (alreadyActive) { + if (query) setTimeout(function () { openSessionSearch(query); }, 400); + if (hasEntry) requestAnimationFrame(function () { + if (!store.get('replayingHistory')) handlePaletteHistoryDone(); + }); + return; + } ctx.selectSession(item.sessionId); if (query) setTimeout(function () { openSessionSearch(query); }, 400); } else { - pendingNav = { slug: item.projectSlug, sessionId: item.sessionId, query: query }; + pendingNav = { slug: item.projectSlug, sessionId: item.sessionId, query: query, entryIndex: hasEntry ? Number(entryIndex) : null }; ctx.switchProject(item.projectSlug); } } diff --git a/lib/public/modules/markdown.js b/lib/public/modules/markdown.js index 2a541c42..42be204b 100644 --- a/lib/public/modules/markdown.js +++ b/lib/public/modules/markdown.js @@ -84,7 +84,7 @@ export function enhanceClaySessionLinks(root) { fragment.appendChild(document.createTextNode(text.slice(offset, match.index))); var button = document.createElement("button"); button.type = "button"; - button.className = "clayos-session-link"; + button.className = "clayos-session-link clayos-record-link"; button.dataset.sessionRef = match[1]; button.setAttribute("aria-label", "Open session" + (match[2] ? " from " + match[2].trim() : "")); var label = document.createElement("span"); diff --git a/lib/server-home-chat.js b/lib/server-home-chat.js index 8f9b9b2c..8bb0c0d8 100644 --- a/lib/server-home-chat.js +++ b/lib/server-home-chat.js @@ -418,7 +418,7 @@ function attachHomeChat(deps) { return sessionReferenceValue; } - function handleMessage(ws, msg) { + function handleMessage(ws, msg, routedProjectSlug) { if (!msg || typeof msg.type !== "string") return false; if (msg.type === "home_mate_stop" || msg.type === "home_mate_permission_response") { var controlUserId = ws._clayUser ? ws._clayUser.id : null; @@ -477,8 +477,8 @@ function attachHomeChat(deps) { if (msg.type === "issue_reference_resolve") { resolveIssueLink(ws, msg); return true; } if (homeDebates.handle(ws, userId, msg)) return true; if (msg.type === "home_clay_ask") { homeClayEntry.start(ws, userId, msg); return true; } - if (msg.type === "home_clay_session_resolve") { homeClaySessionLinks.resolve(ws, msg); return true; } - if (msg.type === "home_clay_log_resolve") { homeClaySessionLinks.resolveLog(ws, msg); return true; } + if (msg.type === "home_clay_session_resolve") { homeClaySessionLinks.resolve(ws, msg, routedProjectSlug); return true; } + if (msg.type === "home_clay_log_resolve") { homeClaySessionLinks.resolveLog(ws, msg, routedProjectSlug); return true; } if (msg.type === "home_mate_debate_plan") { homeDebate.start(ws, userId, msg); diff --git a/lib/server-home-clay-session-links.js b/lib/server-home-clay-session-links.js index 78d8b787..49602b35 100644 --- a/lib/server-home-clay-session-links.js +++ b/lib/server-home-clay-session-links.js @@ -2,23 +2,31 @@ // owner-bound source session. function attachHomeClaySessionLinks(ctx) { - function source(ws, msg) { + function source(ws, msg, routedProjectSlug) { var tap = msg.surface === "search" ? ws._searchClayTap : ws._homeChatTap; + if (!tap && routedProjectSlug) { + var routedProject = ctx.projects.get(routedProjectSlug); + var routedManager = routedProject && routedProject.getSessionManager ? routedProject.getSessionManager() : null; + var routedSession = routedManager && ws._clayActiveSession ? routedManager.sessions.get(ws._clayActiveSession) : null; + return { tap: { mateSlug: routedProjectSlug, sessionId: ws._clayActiveSession }, project: routedProject, session: routedSession }; + } var sourceProject = tap && tap.mateSlug ? ctx.projects.get(tap.mateSlug) : null; var sourceManager = sourceProject && sourceProject.getSessionManager ? sourceProject.getSessionManager() : null; var sourceSession = sourceManager && tap ? sourceManager.sessions.get(tap.sessionId) : null; return { tap: tap, project: sourceProject, session: sourceSession }; } - function resolve(ws, msg) { - var selected = source(ws, msg); - var bound = ctx.workspaceQueryService && selected.session - ? ctx.workspaceQueryService.bindProjectSession({ projectSlug: selected.tap.mateSlug, session: selected.session }) - : null; + function resolve(ws, msg, routedProjectSlug) { + var selected = source(ws, msg, routedProjectSlug); var payload = { type: "home_clay_session_target", requestId: msg.requestId || null, sessionRef: msg.sessionRef || null, status: "error" }; try { + var binding = selected.session ? { projectSlug: selected.tap.mateSlug, session: selected.session } : null; + if (binding && ws && ws._clayUser) binding.viewerId = ws._clayUser.id; + var bound = ctx.workspaceQueryService && binding + ? ctx.workspaceQueryService.bindProjectSession(binding) + : null; if (!bound) throw new Error("The source conversation is no longer available."); - payload.target = bound.resolveSessionNavigation({ sessionRef: msg.sessionRef }); + payload.target = bound.resolveSessionNavigation({ sessionRef: msg.sessionRef, entryIndex: msg.entryIndex }); payload.status = "ready"; } catch (error) { payload.error = error.message || "Session not available."; @@ -26,8 +34,8 @@ function attachHomeClaySessionLinks(ctx) { ctx.sendMessage(ws, payload); } - function resolveLog(ws, msg) { - var selected = source(ws, msg); + function resolveLog(ws, msg, routedProjectSlug) { + var selected = source(ws, msg, routedProjectSlug); var status = selected.project && selected.project.getStatus ? selected.project.getStatus() : null; var bound = ctx.projectLogsService && selected.session && status ? ctx.projectLogsService.bindMate({ diff --git a/lib/server.js b/lib/server.js index c6c199f4..3c2fdf0f 100644 --- a/lib/server.js +++ b/lib/server.js @@ -1630,7 +1630,7 @@ function createServer(opts) { if (dmHandler.handleMessage(ws, msg)) return true; if (mateHandler && mateHandler.handleMessage(ws, msg)) return true; if (emailHandler.handleMessage(ws, msg)) return true; - if (homeChatHandler.handleMessage(ws, msg)) return true; + if (homeChatHandler.handleMessage(ws, msg, routedProjectSlug)) return true; if (homePreferencesHandler.handleMessage(ws, msg)) return true; if (toolsHandler.handleMessage(ws, msg)) return true; return false; diff --git a/lib/sessions.js b/lib/sessions.js index 916ba1f4..6f4ab9ac 100644 --- a/lib/sessions.js +++ b/lib/sessions.js @@ -864,7 +864,9 @@ function createSessionManager(opts) { if (_item && (_item.type === "mention_user" || _item.type === "mention_response")) { console.log("[DEBUG replayHistory] sending mention at index=" + i + " from=" + fromIndex + " total=" + total + " type=" + _item.type + " mate=" + (_item.mateName || "")); } - _send(transform ? transform(_item) : _item); + var replayItem = transform ? transform(_item) : _item; + if (replayItem && typeof replayItem === "object") replayItem = Object.assign({}, replayItem, { _historyIndex: i }); + _send(replayItem); } // Find the last result message in the full history for accurate context data diff --git a/lib/workspace-query-service.js b/lib/workspace-query-service.js index 6ce989be..c0108a7a 100644 --- a/lib/workspace-query-service.js +++ b/lib/workspace-query-service.js @@ -168,6 +168,13 @@ function attachWorkspaceQueryService(ctx) { } else if (source.session.ownerId) { return null; } + if (source.viewerId !== undefined) { + var viewerPrincipal = { + userId: isMultiUser() ? source.viewerId : null, + singleUserOwnerId: isMultiUser() ? null : source.viewerId, + }; + if (!ownsSession(viewerPrincipal, source.session)) return null; + } return createBound({ userId: isMultiUser() ? source.session.ownerId : null, sourceProjectSlug: source.projectSlug, @@ -366,11 +373,13 @@ function attachWorkspaceQueryService(ctx) { function resolveSessionNavigation(principal, ref) { if (typeof ref !== "string" || !/^session:[A-Za-z0-9_-]{24}$/.test(ref)) throw new Error("Invalid session reference."); + var entryIndex = arguments.length > 2 ? arguments[2] : null; + if (entryIndex !== null && entryIndex !== undefined && (!Number.isSafeInteger(entryIndex) || entryIndex < 0)) throw new Error("Invalid session entry reference."); var projects = collect(principal); for (var p = 0; p < projects.length; p++) { var session = findSession(projects[p], ref); if (!session) continue; - return { + var target = { projectSlug: projects[p].projectSlug, projectTitle: projects[p].projectTitle, isMate: projects[p].isMate, @@ -379,6 +388,8 @@ function attachWorkspaceQueryService(ctx) { homeSessionId: session.cliSessionId || "local:" + session.localId, title: cleanMetadata(session.title || "New Session", 160), }; + if (entryIndex !== null && entryIndex !== undefined) target.entryIndex = entryIndex; + return target; } throw new Error("Session not found in the owned workspace."); } @@ -450,7 +461,7 @@ function attachWorkspaceQueryService(ctx) { if (!principal.isClay) throw new Error("Workspace-wide activity is available only to builtin Clay."); return workspaceActivity(principal, args || {}); }, - resolveSessionNavigation: function (args) { return resolveSessionNavigation(principal, args && args.sessionRef); }, + resolveSessionNavigation: function (args) { return resolveSessionNavigation(principal, args && args.sessionRef, args && args.entryIndex); }, listRecentDecisions: function (args) { if (!principal.isClay) throw new Error("Workspace decisions are available only to builtin Clay."); return recentDecisions(principal, args || {}); diff --git a/test/fixtures/issues-browser-noop.js b/test/fixtures/issues-browser-noop.js index 5526f197..4b368ca2 100644 --- a/test/fixtures/issues-browser-noop.js +++ b/test/fixtures/issues-browser-noop.js @@ -1,6 +1,49 @@ export function closeProjectLogs() {} +export function openProjectLog() {} +export function handleProjectLogsState() {} +export function handleProjectLogEntry() {} +export function handleProjectLogCommented() {} +export function handleProjectLogCommentReviewed() {} +export function handleProjectLogDeleted() {} +export function handleProjectLogUpdated() {} +export function handleProjectLogsError() {} +export function handleScheduledTaskResultsState() {} +export function handleScheduledTaskResultCreated() {} +export function handleScheduledTaskResultSession() {} export function closeScheduledTasks() {} +export function openScheduledTasks() {} +export function handleScheduledTaskMessage() {} +export function handleScheduledTaskSessionSwitched() {} export function closeNotesBrowser() {} export function closeFileViewer() {} +export function openFile() {} +export function handleFsList() {} +export function handleFsRead() {} +export function handleFileChanged() {} +export function handleDirChanged() {} +export function handleFileHistory() {} +export function handleGitDiff() {} +export function handleFileAt() {} +export function refreshIfOpen() {} +export function getPendingNavigate() { return null; } +export function handleFsSearch() {} +export function presentMarkdownEdit() {} +export function resetFileBrowser() {} +export function openWorkingTreeDiff() {} +export function loadRootDirectory() {} +export function reopenFileViewer() {} +export function focusFileTree() {} +export function openNotesBrowser() {} +export function isNotesBrowserOpen() { return false; } +export function handleNoteDeleteResult() {} export function closeTerminal() {} +export function resetTerminals() {} +export function handleTermList() {} +export function handleTermCreated() {} +export function sendTerminalCommand() {} +export function handleTermOutput() {} +export function handleTermResized() {} +export function handleTermExited() {} +export function handleTermClosed() {} +export function openTerminal() {} export function setTerminalTheme() {} diff --git a/test/fixtures/issues-browser.html b/test/fixtures/issues-browser.html index 03160207..18f17eea 100644 --- a/test/fixtures/issues-browser.html +++ b/test/fixtures/issues-browser.html @@ -26,8 +26,8 @@
@@ -66,6 +66,10 @@Browser fixture: production Issues UI over the real Issues WebSocket service. Paid Driver startup is disabled.
+ + + +