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:
2026-10-10 19:57:52 -04:00
co-authored by Claude Opus 5.5
parent dee1843986
commit 6ffe07116f
5 changed files with 68 additions and 16 deletions
+11
View File
@@ -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
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();