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
|
devdesk (0.7.2) bookworm; urgency=medium
|
||||||
|
|
||||||
* Fix: Go to file failed in big non-git folders such as /root/code: the
|
* 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)
|
message=$(jq -rM '.message // "Claude is waiting for your input"' "$state_file" 2>/dev/null)
|
||||||
url=""
|
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)" \
|
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,
|
'{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))}')
|
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
|
sleep 2.5
|
||||||
[ "$(posts)" -eq 1 ]
|
[ "$(posts)" -eq 1 ]
|
||||||
run jq -r '[.session, .url, (.text | test("Claude needs you in alpha"))] | @tsv' "$TMP/posts"
|
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
|
# answered within the delay: nothing is posted
|
||||||
echo '{"hook_event_name":"PostToolUse"}' | hook
|
echo '{"hook_event_name":"PostToolUse"}' | hook
|
||||||
|
|||||||
+11
-4
@@ -8,11 +8,12 @@ import { ago } from './sessions.js';
|
|||||||
|
|
||||||
const KEY = 'devdesk.inbox';
|
const KEY = 'devdesk.inbox';
|
||||||
const MAX = 30;
|
const MAX = 30;
|
||||||
|
const MAX_AGE = 7 * 86400 * 1000;
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
try {
|
try {
|
||||||
const list = JSON.parse(localStorage.getItem(KEY) || '[]');
|
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 {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -45,6 +46,7 @@ export class Inbox {
|
|||||||
this.events = load();
|
this.events = load();
|
||||||
this.waiting = [];
|
this.waiting = [];
|
||||||
this.alerts = [];
|
this.alerts = [];
|
||||||
|
this.known = null; // session names that exist, once the list has loaded
|
||||||
this.list = panel.querySelector('.lt-notif-panel-list');
|
this.list = panel.querySelector('.lt-notif-panel-list');
|
||||||
panel.querySelector('.lt-notif-panel-clear').addEventListener('click', () => this.markAllRead());
|
panel.querySelector('.lt-notif-panel-clear').addEventListener('click', () => this.markAllRead());
|
||||||
window.addEventListener('storage', (event) => {
|
window.addEventListener('storage', (event) => {
|
||||||
@@ -78,9 +80,10 @@ export class Inbox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Live state: other sessions waiting on you, firing alerts.
|
// Live state: other sessions waiting on you, firing alerts.
|
||||||
setLive({ waiting, alerts }) {
|
setLive({ waiting, alerts, sessions }) {
|
||||||
if (waiting) this.waiting = waiting;
|
if (waiting) this.waiting = waiting;
|
||||||
if (alerts) this.alerts = alerts;
|
if (alerts) this.alerts = alerts;
|
||||||
|
if (sessions) this.known = new Set(sessions);
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +94,7 @@ export class Inbox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
unread() {
|
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() {
|
render() {
|
||||||
@@ -155,12 +158,16 @@ export class Inbox {
|
|||||||
nodes.push(el('div', { class: 'dd-notif-section' }, 'Recent'));
|
nodes.push(el('div', { class: 'dd-notif-section' }, 'Recent'));
|
||||||
for (const e of recent.slice(0, 12)) {
|
for (const e of recent.slice(0, 12)) {
|
||||||
const kind = KINDS[e.kind] ?? { tone: 'cyan' };
|
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({
|
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(() => {
|
onclick: close(() => {
|
||||||
this.events = this.events.map((x) => (x.id === e.id ? { ...x, read: true } : x));
|
this.events = this.events.map((x) => (x.id === e.id ? { ...x, read: true } : x));
|
||||||
save(this.events);
|
save(this.events);
|
||||||
if (e.url) window.open(e.url, '_blank', 'noopener');
|
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);
|
else if (e.session) this.hooks.openSession(e.session);
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
+42
-10
@@ -58,6 +58,8 @@ const ui = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const urlSession = sessionFromSearch(window.location.search);
|
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 = {
|
const state = {
|
||||||
session: urlSession, // null = picker
|
session: urlSession, // null = picker
|
||||||
conn: null,
|
conn: null,
|
||||||
@@ -169,7 +171,11 @@ function recordTransitions(sessions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderPanelsBadge() {
|
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 } = {}) {
|
function onSessionData({ fetched = false } = {}) {
|
||||||
@@ -316,14 +322,19 @@ function updateUrl(session) {
|
|||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
if (session) url.searchParams.set('arg', session);
|
if (session) url.searchParams.set('arg', session);
|
||||||
else url.searchParams.delete('arg');
|
else url.searchParams.delete('arg');
|
||||||
|
url.searchParams.delete('existing');
|
||||||
if (url.href !== window.location.href) window.history.replaceState(null, '', url);
|
if (url.href !== window.location.href) window.history.replaceState(null, '', url);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- connection
|
// ---------------------------------------------------------------- connection
|
||||||
|
|
||||||
// reconnect: automatic re-attach after a drop. It must not re-create a session
|
// existing: go to a session that should already exist (reconnects, the
|
||||||
// that was ended or renamed meanwhile, so it asks for --existing (devdesk#4).
|
// switcher, inbox entries, notification links). It must not re-create one that
|
||||||
function connect(session, { reconnect = false } = {}) {
|
// 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);
|
clearTimeout(state.retryTimer);
|
||||||
state.conn?.dispose();
|
state.conn?.dispose();
|
||||||
state.session = session;
|
state.session = session;
|
||||||
@@ -339,7 +350,7 @@ function connect(session, { reconnect = false } = {}) {
|
|||||||
term.reset();
|
term.reset();
|
||||||
|
|
||||||
const conn = new TtydConnection({
|
const conn = new TtydConnection({
|
||||||
arg: session && reconnect ? ['--existing', session] : session,
|
arg: existingOnly ? ['--existing', session] : session,
|
||||||
size: () => ({ cols: term.cols, rows: term.rows }),
|
size: () => ({ cols: term.cols, rows: term.rows }),
|
||||||
onOpen: () => {
|
onOpen: () => {
|
||||||
state.openedAt = Date.now();
|
state.openedAt = Date.now();
|
||||||
@@ -373,6 +384,25 @@ function onClosed(code) {
|
|||||||
// few seconds of opening means the session could not start (the reason is
|
// few seconds of opening means the session could not start (the reason is
|
||||||
// printed in the terminal), so don't retry forever.
|
// printed in the terminal), so don't retry forever.
|
||||||
const failedFast = code !== 1000 && Date.now() - state.openedAt < 4000;
|
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) {
|
if (failedFast) {
|
||||||
showOverlay({
|
showOverlay({
|
||||||
title: 'Session did not start',
|
title: 'Session did not start',
|
||||||
@@ -515,7 +545,7 @@ function renderSwitcher() {
|
|||||||
return el('button', {
|
return el('button', {
|
||||||
type: 'button', role: 'menuitem',
|
type: 'button', role: 'menuitem',
|
||||||
class: `dd-menu-item dd-switch-item is-${status.key}${s.name === state.session ? ' is-current' : ''}`,
|
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: `lt-dot ${status.dot}` }),
|
||||||
el('span', { class: 'dd-switch-name' }, s.name),
|
el('span', { class: 'dd-switch-name' }, s.name),
|
||||||
@@ -591,7 +621,7 @@ function buildCommands() {
|
|||||||
const status = sessionStatus(s);
|
const status = sessionStatus(s);
|
||||||
add({
|
add({
|
||||||
id: `switch:${s.name}`, group: 'Sessions', label: `Switch to ${s.name}`, hint: status.label, keywords: s.dir,
|
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() });
|
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'),
|
badge: $('inbox-badge'),
|
||||||
panel: $('inbox-panel'),
|
panel: $('inbox-panel'),
|
||||||
hooks: {
|
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: () => {
|
showAlerts: () => {
|
||||||
setSidebar(true);
|
setSidebar(true);
|
||||||
$('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' });
|
$('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||||
@@ -957,7 +988,7 @@ function wireChrome() {
|
|||||||
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshRepo();
|
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshRepo();
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
ui.attention.addEventListener('click', () => {
|
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();
|
wireUploads();
|
||||||
refreshAlerts();
|
refreshAlerts();
|
||||||
await sessionsPanel.refresh();
|
await sessionsPanel.refresh();
|
||||||
connect(urlSession ?? defaultSession(sessionsPanel.data));
|
if (urlSession) connect(urlSession, { existing: urlExisting });
|
||||||
|
else connect(defaultSession(sessionsPanel.data), { existing: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
boot();
|
boot();
|
||||||
|
|||||||
Reference in New Issue
Block a user