fix: old links never re-create ended sessions
Inbox entries, the switcher, palette switches, the attention chip and Matrix links (?arg=NAME&existing=1) attach with --existing; an ended session gets an explanation and an explicit "Start again". Inbox entries for ended sessions can't resurrect them; entries expire after 7 days. Closes #38 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vendored
+11
@@ -1,3 +1,14 @@
|
||||
devdesk (0.7.3) bookworm; urgency=medium
|
||||
|
||||
* Fix (#38): links that mean "go to that session" no longer re-create one
|
||||
that was ended. The 🔔 inbox, the session switcher, palette switches, the
|
||||
"needs you" chip and Matrix links (now ?arg=NAME&existing=1) open sessions
|
||||
existing-only; an ended one shows "NAME has ended" with an explicit
|
||||
"Start NAME again". Inbox entries for ended sessions are marked as such,
|
||||
and entries older than 7 days are dropped.
|
||||
|
||||
-- Jared Vititoe <jjvititoe1@gmail.com> Sat, 10 Oct 2026 19:57:52 -0400
|
||||
|
||||
devdesk (0.7.2) bookworm; urgency=medium
|
||||
|
||||
* Fix: Go to file failed in big non-git folders such as /root/code: the
|
||||
|
||||
@@ -28,7 +28,9 @@ attached=$(dd_tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev
|
||||
|
||||
message=$(jq -rM '.message // "Claude is waiting for your input"' "$state_file" 2>/dev/null)
|
||||
url=""
|
||||
[ -z "$DEVDESK_PUBLIC_URL" ] || url="${DEVDESK_PUBLIC_URL%/}/?arg=$session"
|
||||
# existing=1: if the session was ended by the time you tap, say so instead of
|
||||
# starting a new Claude there (devdesk#38).
|
||||
[ -z "$DEVDESK_PUBLIC_URL" ] || url="${DEVDESK_PUBLIC_URL%/}/?arg=$session&existing=1"
|
||||
body=$(jq -cMn --arg s "$session" --arg m "$message" --arg u "$url" --arg h "$(hostname)" \
|
||||
'{session: $s, message: $m, url: (if $u == "" then null else $u end), host: $h,
|
||||
text: ("Claude needs you in " + $s + ": " + $m + (if $u == "" then "" else " — " + $u end))}')
|
||||
|
||||
+1
-1
@@ -310,7 +310,7 @@ posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; }
|
||||
sleep 2.5
|
||||
[ "$(posts)" -eq 1 ]
|
||||
run jq -r '[.session, .url, (.text | test("Claude needs you in alpha"))] | @tsv' "$TMP/posts"
|
||||
[ "$output" = "$(printf 'alpha\thttps://dev.example.org/?arg=alpha\ttrue')" ]
|
||||
[ "$output" = "$(printf 'alpha\thttps://dev.example.org/?arg=alpha&existing=1\ttrue')" ]
|
||||
|
||||
# answered within the delay: nothing is posted
|
||||
echo '{"hook_event_name":"PostToolUse"}' | hook
|
||||
|
||||
+11
-4
@@ -8,11 +8,12 @@ import { ago } from './sessions.js';
|
||||
|
||||
const KEY = 'devdesk.inbox';
|
||||
const MAX = 30;
|
||||
const MAX_AGE = 7 * 86400 * 1000;
|
||||
|
||||
function load() {
|
||||
try {
|
||||
const list = JSON.parse(localStorage.getItem(KEY) || '[]');
|
||||
return Array.isArray(list) ? list : [];
|
||||
return Array.isArray(list) ? list.filter((e) => Date.now() - e.at < MAX_AGE) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -45,6 +46,7 @@ export class Inbox {
|
||||
this.events = load();
|
||||
this.waiting = [];
|
||||
this.alerts = [];
|
||||
this.known = null; // session names that exist, once the list has loaded
|
||||
this.list = panel.querySelector('.lt-notif-panel-list');
|
||||
panel.querySelector('.lt-notif-panel-clear').addEventListener('click', () => this.markAllRead());
|
||||
window.addEventListener('storage', (event) => {
|
||||
@@ -78,9 +80,10 @@ export class Inbox {
|
||||
}
|
||||
|
||||
// Live state: other sessions waiting on you, firing alerts.
|
||||
setLive({ waiting, alerts }) {
|
||||
setLive({ waiting, alerts, sessions }) {
|
||||
if (waiting) this.waiting = waiting;
|
||||
if (alerts) this.alerts = alerts;
|
||||
if (sessions) this.known = new Set(sessions);
|
||||
this.render();
|
||||
}
|
||||
|
||||
@@ -91,7 +94,7 @@ export class Inbox {
|
||||
}
|
||||
|
||||
unread() {
|
||||
return this.events.filter((e) => !e.read && e.kind !== 'waiting').length;
|
||||
return this.events.filter((e) => !e.read && e.kind !== 'waiting' && !(this.known && e.session && !e.url && !this.known.has(e.session))).length;
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -155,12 +158,16 @@ export class Inbox {
|
||||
nodes.push(el('div', { class: 'dd-notif-section' }, 'Recent'));
|
||||
for (const e of recent.slice(0, 12)) {
|
||||
const kind = KINDS[e.kind] ?? { tone: 'cyan' };
|
||||
// An entry about a session that has since ended must not bring it back (devdesk#38).
|
||||
const ended = !e.url && e.session && this.known && !this.known.has(e.session);
|
||||
nodes.push(item({
|
||||
dot: e.read ? 'read' : kind.tone, unread: !e.read, title: e.text, meta: when(e.at),
|
||||
dot: e.read || ended ? 'read' : kind.tone, unread: !e.read && !ended, title: e.text,
|
||||
meta: `${when(e.at)}${ended ? ' · session ended' : ''}`,
|
||||
onclick: close(() => {
|
||||
this.events = this.events.map((x) => (x.id === e.id ? { ...x, read: true } : x));
|
||||
save(this.events);
|
||||
if (e.url) window.open(e.url, '_blank', 'noopener');
|
||||
else if (ended) this.hooks.ended?.(e.session);
|
||||
else if (e.session) this.hooks.openSession(e.session);
|
||||
}),
|
||||
}));
|
||||
|
||||
+42
-10
@@ -58,6 +58,8 @@ const ui = {
|
||||
};
|
||||
|
||||
const urlSession = sessionFromSearch(window.location.search);
|
||||
// Notification links (Matrix) add existing=1: open, never re-create (devdesk#38).
|
||||
const urlExisting = new URLSearchParams(window.location.search).get('existing') === '1';
|
||||
const state = {
|
||||
session: urlSession, // null = picker
|
||||
conn: null,
|
||||
@@ -169,7 +171,11 @@ function recordTransitions(sessions) {
|
||||
}
|
||||
|
||||
function renderPanelsBadge() {
|
||||
inbox?.setLive({ waiting: waitingOthers, alerts: lastAlerts?.alerts ?? [] });
|
||||
inbox?.setLive({
|
||||
waiting: waitingOthers,
|
||||
alerts: lastAlerts?.alerts ?? [],
|
||||
sessions: sessionsPanel?.data ? sessionsPanel.data.sessions.map((x) => x.name) : null,
|
||||
});
|
||||
}
|
||||
|
||||
function onSessionData({ fetched = false } = {}) {
|
||||
@@ -316,14 +322,19 @@ function updateUrl(session) {
|
||||
const url = new URL(window.location.href);
|
||||
if (session) url.searchParams.set('arg', session);
|
||||
else url.searchParams.delete('arg');
|
||||
url.searchParams.delete('existing');
|
||||
if (url.href !== window.location.href) window.history.replaceState(null, '', url);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- connection
|
||||
|
||||
// reconnect: automatic re-attach after a drop. It must not re-create a session
|
||||
// that was ended or renamed meanwhile, so it asks for --existing (devdesk#4).
|
||||
function connect(session, { reconnect = false } = {}) {
|
||||
// existing: go to a session that should already exist (reconnects, the
|
||||
// switcher, inbox entries, notification links). It must not re-create one that
|
||||
// was ended or renamed meanwhile, so it asks for --existing (devdesk#4, #38).
|
||||
// Creating is explicit: a typed name, the New dialog, the picker, recents.
|
||||
function connect(session, { existing = false, reconnect = false } = {}) {
|
||||
const existingOnly = Boolean(session) && (existing || reconnect);
|
||||
state.existingOnly = existingOnly;
|
||||
clearTimeout(state.retryTimer);
|
||||
state.conn?.dispose();
|
||||
state.session = session;
|
||||
@@ -339,7 +350,7 @@ function connect(session, { reconnect = false } = {}) {
|
||||
term.reset();
|
||||
|
||||
const conn = new TtydConnection({
|
||||
arg: session && reconnect ? ['--existing', session] : session,
|
||||
arg: existingOnly ? ['--existing', session] : session,
|
||||
size: () => ({ cols: term.cols, rows: term.rows }),
|
||||
onOpen: () => {
|
||||
state.openedAt = Date.now();
|
||||
@@ -373,6 +384,25 @@ function onClosed(code) {
|
||||
// few seconds of opening means the session could not start (the reason is
|
||||
// printed in the terminal), so don't retry forever.
|
||||
const failedFast = code !== 1000 && Date.now() - state.openedAt < 4000;
|
||||
if (failedFast && state.existingOnly) {
|
||||
// Most likely ended or renamed since the link was made: say so, and only
|
||||
// start it again when asked.
|
||||
const name = state.session;
|
||||
sessionsPanel?.reload().then(() => {
|
||||
const known = sessionsPanel.data?.sessions?.some((x) => x.name === name);
|
||||
if (known) {
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
setWsState('disconnected', 'ended');
|
||||
showOverlay({
|
||||
title: `${name} has ended`,
|
||||
text: `The session "${name}" no longer exists: it was ended or renamed. Its conversation is still in Claude's history.`,
|
||||
actions: [pickerAction, { label: `Start ${name} again`, run: () => connect(name) }],
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (failedFast) {
|
||||
showOverlay({
|
||||
title: 'Session did not start',
|
||||
@@ -515,7 +545,7 @@ function renderSwitcher() {
|
||||
return el('button', {
|
||||
type: 'button', role: 'menuitem',
|
||||
class: `dd-menu-item dd-switch-item is-${status.key}${s.name === state.session ? ' is-current' : ''}`,
|
||||
title: s.dir || '', onclick: choose(() => connect(s.name)),
|
||||
title: s.dir || '', onclick: choose(() => connect(s.name, { existing: true })),
|
||||
},
|
||||
el('span', { class: `lt-dot ${status.dot}` }),
|
||||
el('span', { class: 'dd-switch-name' }, s.name),
|
||||
@@ -591,7 +621,7 @@ function buildCommands() {
|
||||
const status = sessionStatus(s);
|
||||
add({
|
||||
id: `switch:${s.name}`, group: 'Sessions', label: `Switch to ${s.name}`, hint: status.label, keywords: s.dir,
|
||||
icon: status.key === 'waiting' ? '!' : s.running ? '●' : '○', run: () => connect(s.name),
|
||||
icon: status.key === 'waiting' ? '!' : s.running ? '●' : '○', run: () => connect(s.name, { existing: true }),
|
||||
});
|
||||
}
|
||||
add({ id: 'new', group: 'Sessions', icon: '+', label: 'New session…', keywords: 'create open project', run: () => sessionsPanel.newDialog() });
|
||||
@@ -866,7 +896,8 @@ function wireChrome() {
|
||||
badge: $('inbox-badge'),
|
||||
panel: $('inbox-panel'),
|
||||
hooks: {
|
||||
openSession: (name) => connect(name),
|
||||
openSession: (name) => connect(name, { existing: true }),
|
||||
ended: (name) => toast.info(`${name} has ended; its conversation is still in Claude's history`),
|
||||
showAlerts: () => {
|
||||
setSidebar(true);
|
||||
$('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||
@@ -957,7 +988,7 @@ function wireChrome() {
|
||||
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshRepo();
|
||||
}, 60 * 1000);
|
||||
ui.attention.addEventListener('click', () => {
|
||||
if (ui.attention.dataset.target) connect(ui.attention.dataset.target);
|
||||
if (ui.attention.dataset.target) connect(ui.attention.dataset.target, { existing: true });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1057,7 +1088,8 @@ async function boot() {
|
||||
wireUploads();
|
||||
refreshAlerts();
|
||||
await sessionsPanel.refresh();
|
||||
connect(urlSession ?? defaultSession(sessionsPanel.data));
|
||||
if (urlSession) connect(urlSession, { existing: urlExisting });
|
||||
else connect(defaultSession(sessionsPanel.data), { existing: true });
|
||||
}
|
||||
|
||||
boot();
|
||||
|
||||
Reference in New Issue
Block a user