feat: v0.12.0 Settings window, per-session Remote Control, delete conversations
build-deb / Lint, test, package (push) Successful in 4m59s
build-deb / Deploy to CT 126 (push) Successful in 14s
build-deb / Notify on failure (push) Skipped

- Settings (⋯ → Settings…, palette): theme, terminal text size, interface
  size, notifications, Remote Control & privacy. The ⋯ menu shrinks.
- Remote Control per session: registry choice > Settings default >
  DEVDESK_REMOTE_CONTROL. Switching an idle session restarts its Claude
  on the same conversation; busy sessions change at the next start.
  rc_active comes from the running process; RC tag on sessions.
- --delete-convo: remove a conversation's local transcript and subagent
  files; refused while in use. Delete… in Search past conversations.
Closes #61

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
This commit is contained in:
2026-11-03 21:23:29 -05:00
co-authored by Claude Opus 5.5
parent 65114165e7
commit 20134c1e58
14 changed files with 454 additions and 50 deletions
+4 -2
View File
@@ -65,6 +65,8 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
- **Ops guard:** `devdesk-guard` (a PreToolUse hook) makes Claude ask before destructive commands (`pct destroy`, `zfs destroy`, Ceph pool/OSD removal, `rm -r /…`, `git push --force`, `reboot`…), even in auto mode. `DEVDESK_GUARD=0` turns it off; `/etc/devdesk/guard.local` adds rules (`regex<TAB>reason`). Bash commands are logged per session, and sessions are tagged with the hosts they touch (ssh targets, CT/VM ids).
- **Search past conversations** (⋯ menu, palette): full text over every Claude conversation on the host; **Resume** it in a new session or **Fork** it.
- **Plan usage:** the 5-hour and weekly windows as meters in the footer, with when each resets (`↻ 14:30`). If the pace so far would use a window up before it resets, the meter shows `⚠ full ~13:50` instead. A toast warns at 90 %, and Matrix gets one message per window when you're away (`DEVDESK_USAGE_WARN`). Sessions also get a **PR** chip when their branch has a pull request.
- **Settings** (⋯ → Settings…): theme, terminal text size and interface size (per browser), desktop notifications, and **Remote Control per session**. With Remote Control on, a session also shows in the Claude app and at claude.ai/code, and Anthropic stores its transcript to sync devices (30 days, or 5 years if "Help improve Claude" is on). It's **off for new sessions** unless you change the default. Each running session has its own switch, which restarts that Claude on the same conversation when it's idle. Sessions that sync show an **RC** tag.
- **Delete a conversation:** Search past conversations → **Delete…** removes its local transcript (`~/.claude/projects`) for good; a copy synced by Remote Control is deleted at claude.ai/code.
- **Quiet phone at the desk:** while a tab shows a session, Remote Control holds back phone pushes for it (client-presence marker, expires ~2 min after you leave).
- **Context:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use, plan usage and cost to `/var/lib/devdesk/context/<session>.json`. The Sessions panel shows it as a bar and Claude's own footer shows a one-line summary. On a Pro/Max plan that summary shows 5-hour plan usage; the dollar figure Claude reports there is an API-price estimate, not a bill, so it appears only for API-key billing.
- **Light mode:** the terminal switches palettes with the page. DevDesk starts Claude with its `dark-ansi` theme so Claude draws with the terminal's 16 colours, and the light palette maps bright white to ink; tmux and the status line use ANSI colours too. A session picks up Claude's theme the next time Claude starts in it (`/exit`, then ⏎ resumes the same conversation).
@@ -91,12 +93,12 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
The **☀/◐** button switches light and dark mode (saved per browser, shared with the other web_template apps' `lt_theme` preference). Text size, desktop notifications (with a mute), the keyboard shortcuts sheet and fullscreen are in the **⋯** menu; everything is also in the command palette. In Chromium, fullscreen also captures Ctrl+W/Ctrl+T, which browsers otherwise keep for themselves.
## Configuration
- **Put your changes in `/etc/devdesk/env.local`.** It's read after the packaged `env` and never shipped, so `apt upgrade` never asks about it. Example: `DEVDESK_REMOTE_CONTROL=1`.
- **Put your changes in `/etc/devdesk/env.local`.** It's read after the packaged `env` and never shipped, so `apt upgrade` never asks about it. Example: `DEVDESK_GUARD=0`.
- `/etc/devdesk/env` holds the packaged defaults; don't edit it. Its keys:
- port, bind address, projects root and `DEVDESK_DEFAULT_DIR`
- `DEVDESK_GITEA_URL` / `DEVDESK_GITEA_ALIASES` for the Repo/CI panel. Public repos need no token; for private ones put a `read:repository` token in `/etc/devdesk/gitea-token`
- `CLAUDE_BIN` and `CLAUDE_ARGS`
- `DEVDESK_REMOTE_CONTROL=1`, which also starts each session with Claude Remote Control so it can be continued from the Claude app
- `DEVDESK_REMOTE_CONTROL=1`: the Remote Control default for new sessions until one is chosen in Settings (which then wins; each session can also choose its own)
- `DEVDESK_PUBLIC_URL`, `DEVDESK_MATRIX_WEBHOOK_FILE`, `DEVDESK_MATRIX_DELAY`, `DEVDESK_MATRIX_QUIET` and `DEVDESK_USAGE_WARN` for the Matrix notifications
- `/etc/devdesk/secret.env` is generated on install. It holds the ttyd credential that NPM must inject; see [docs/DEPLOY.md](docs/DEPLOY.md).
- `/etc/devdesk/tmux.local.conf` is optional and is sourced after the packaged `tmux.conf`.
+79 -2
View File
@@ -80,6 +80,8 @@ cmd_list() {
--argjson ctx "$(per_session_json "$DEVDESK_CONTEXT_DIR")" \
--argjson st "$(per_session_json "$DEVDESK_STATUS_DIR")" \
--argjson tags "$(per_session_json "$DEVDESK_STATE_DIR/tags")" \
--arg rclive "$(dd_rc_live)" \
--argjson rcdef "$(dd_rc_default)" \
--arg dflt "$DEVDESK_DEFAULT_SESSION" \
--arg root "$DEVDESK_PROJECTS_ROOT" '
def rows($s): $s | split("\n") | map(select(length > 0) | split("\t"));
@@ -98,7 +100,10 @@ cmd_list() {
activity: null, windows: 0,
claude_session_id: (.value.claude_session_id // null),
worktree: (.value.worktree // null)}))
) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null), tags: ($tags[.name].hosts // [])}),
) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null), tags: ($tags[.name].hosts // []),
remote_control: (($known[.name].remote_control) as $own | if $own == null then $rcdef else $own end),
rc_active: (.name as $n | $rclive | split("\n") | any(.[]; . == $n))}),
remote_control_default: $rcdef,
projects: ([{name: $dflt, dir: $root}]
+ [rows($proj)[] | {name: .[0], git: (.[1] == "1"), dir: (.[2:] | join("\t"))}
| select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}'
@@ -203,6 +208,78 @@ cmd_unsplit() {
jq -cMn --argjson c "$closed" '{ok: true, closed: $c}'
}
# Sessions whose running Claude has Remote Control on, read from the processes
# themselves (devdesk#61), one name per line.
dd_rc_live() {
local session window pid child
dd_server_up || return 0
while IFS=$'\t' read -r session window pid; do
[ "$window" = claude ] || continue
for child in $(pgrep -P "$pid" 2>/dev/null); do
if tr '\0' '\n' <"/proc/$child/cmdline" 2>/dev/null | grep -qx -- --remote-control; then
printf '%s\n' "$session"
break
fi
done
done < <(dd_tmux list-panes -a -F $'#{session_name}\t#{window_name}\t#{pane_pid}' 2>/dev/null)
}
# The Claude process of a session's claude window, if any.
dd_claude_pid() {
local pid
pid=$(dd_tmux list-panes -t "=$1:claude" -F '#{pane_pid}' 2>/dev/null | head -n1)
[ -n "$pid" ] && pgrep -P "$pid" 2>/dev/null | head -n1
}
# --remote-control NAME on|off (devdesk#61): the session's own choice. A running,
# idle Claude restarts on the same conversation right away; one that is working
# or waiting gets it at its next start. Prints {ok, remote_control, applied}.
cmd_remote_control() {
[ $# -eq 2 ] || dd_die "usage: devdesk attach --remote-control NAME on|off"
local name=$1 value applied=saved pid state
dd_valid_name "$name" || dd_die "invalid session name: $name"
case $2 in on) value=true ;; off) value=false ;; *) dd_die "on or off" ;; esac
{ session_exists "$name"; } || dd_die "no session named '$name'"
# shellcheck disable=SC2016 # jq variables
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {remote_control: ($v == "true")})' --arg n "$name" --arg v "$value"
if dd_server_up && dd_tmux has-session -t "=$name" 2>/dev/null && pid=$(dd_claude_pid "$name") && [ -n "$pid" ]; then
state=$(jq -rM '.state // empty' "$DEVDESK_STATUS_DIR/$name.json" 2>/dev/null) || state="" # no state yet: idle
if [ "$state" = working ] || [ "$state" = waiting ]; then
applied=next-start
else
mkdir -p "$DEVDESK_STATE_DIR/restart"
touch "$DEVDESK_STATE_DIR/restart/$name"
kill -TERM "$pid" 2>/dev/null || true
applied=now
fi
fi
jq -cMn --argjson v "$value" --arg a "$applied" '{ok: true, remote_control: $v, applied: $a}'
}
# Remote Control default for new sessions: Settings, else DEVDESK_REMOTE_CONTROL. true/false.
dd_rc_default() {
local def
def=$(jq -rM '.remote_control_default | if . == null then empty else tostring end' "$DEVDESK_STATE_DIR/settings.json" 2>/dev/null)
if [ -n "$def" ]; then echo "$def"; elif [ "${DEVDESK_REMOTE_CONTROL:-0}" = 1 ]; then echo true; else echo false; fi
}
# --settings [remote-control-default on|off] (devdesk#61): DevDesk-wide settings
# kept in $DEVDESK_STATE_DIR/settings.json; prints them.
cmd_settings() {
local file="$DEVDESK_STATE_DIR/settings.json" tmp
if [ $# -eq 2 ]; then
[ "$1" = remote-control-default ] || dd_die "unknown setting: $1"
case $2 in on | off) ;; *) dd_die "on or off" ;; esac
tmp=$(mktemp "$DEVDESK_STATE_DIR/.settings.XXXXXX")
jq -cM --argjson v "$([ "$2" = on ] && echo true || echo false)" '. + {remote_control_default: $v}' "$file" 2>/dev/null >"$tmp" ||
jq -cMn --argjson v "$([ "$2" = on ] && echo true || echo false)" '{remote_control_default: $v}' >"$tmp"
mv -f "$tmp" "$file"
elif [ $# -ne 0 ]; then
dd_die "usage: devdesk attach --settings [remote-control-default on|off]"
fi
jq -cMn --argjson d "$(dd_rc_default)" '{ok: true, remote_control_default: $d}'
}
cmd_rename() {
[ $# -eq 2 ] || dd_die "usage: devdesk rename OLD NEW"
local old=$1 new=$2 running=0
@@ -388,7 +465,7 @@ cmd_attach() {
dd_linger
return
;;
--peek | --search | --last-reply | --unsplit)
--peek | --search | --last-reply | --unsplit | --remote-control | --settings | --delete-convo)
local action=${1#--}
shift
"cmd_${action//-/_}" "$@"
+19
View File
@@ -1,3 +1,22 @@
devdesk (0.12.0) bookworm; urgency=medium
* Settings window, from the ⋯ menu or the palette (#61):
- Theme, terminal text size and interface size (per browser).
- Desktop notifications.
- Remote Control and privacy.
The ⋯ menu shrinks to five entries.
* Remote Control per session (#61):
- A default for new sessions and a switch for each running session.
- Switching an idle session restarts its Claude on the same
conversation; a busy one changes at its next start.
- Sessions that sync show an RC tag, read from the running process.
- Server side: --remote-control and --settings.
* Delete a conversation from Search past conversations (#61): its local
transcript and subagent files, for good (--delete-convo). Refused
while a running session uses it.
-- Jared Vititoe <jjvititoe1@gmail.com> Tue, 03 Nov 2026 21:25:00 -0500
devdesk (0.11.0) bookworm; urgency=medium
* Explorer works like VS Code's (#54):
+3 -1
View File
@@ -62,5 +62,7 @@ DEVDESK_USAGE_WARN=90
# /etc/devdesk/guard.local, one "regex<TAB>reason" per line.
DEVDESK_GUARD=1
# 1 = also enable Remote Control so sessions can be continued from the Claude app.
# Remote Control default for new sessions (1 = on: continue them from the Claude app;
# Anthropic then stores the transcript to sync devices). Settings in the web UI
# overrides this, and each session can choose its own (devdesk#61).
DEVDESK_REMOTE_CONTROL=0
+11 -1
View File
@@ -27,7 +27,8 @@ RESET=$'\033[0m'
common_args() {
printf '%s\0' --settings "$SETTINGS"
if [ "${DEVDESK_REMOTE_CONTROL:-0}" = "1" ]; then
# Per session since devdesk#61 (Settings → Remote Control).
if [ "$(dd_rc_wanted "$NAME")" = 1 ]; then
printf '%s\0' --remote-control "devdesk-$NAME"
fi
if [ ${#EXTRA[@]} -gt 0 ]; then
@@ -97,6 +98,15 @@ while :; do
if [ "$mode" = new ]; then run_new; else run_resume; fi
rc=$?
NAME=$(dd_current_session)
# Settings changed Remote Control: Claude was stopped to restart on the same
# conversation with the new setting (devdesk#61), no menu.
restart="$DEVDESK_STATE_DIR/restart/$NAME"
if [ -e "$restart" ]; then
rm -f "$restart"
printf '\n%s◈ Restarting Claude with Remote Control %s…%s\n' "$MUTED" "$([ "$(dd_rc_wanted "$NAME")" = 1 ] && echo on || echo off)" "$RESET"
mode=resume
continue
fi
while :; do
menu "$rc"
IFS= read -rsn1 key || exit 0
+27
View File
@@ -115,3 +115,30 @@ cmd_presence() {
setsid -f bash -c 'sleep 120; [ -n "$(find "$1" -newermt "-110 seconds" 2>/dev/null)" ] || rm -f "$1"' _ "$marker" \
</dev/null >/dev/null 2>&1 3>&- || true
}
# --delete-convo ID (devdesk#61): permanently delete a conversation's local files
# (the transcript ~/.claude/projects/<folder>/<id>.jsonl and its <id>/ folder of
# subagent transcripts and tool results). Refused while a running session uses
# it. Dormant sessions that would resume it forget it (they start fresh).
# Copies kept by Anthropic (Remote Control, the API) are not touched: those are
# deleted at claude.ai.
cmd_delete_convo() {
[ $# -eq 1 ] || dd_die "usage: devdesk attach --delete-convo ID"
local id=$1 file dir other
[[ $id =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] || dd_die "bad conversation id: $id"
file=$(dd_convo_file "$id") || true
{ [ -n "$file" ] && [ -f "$file" ]; } || dd_die "no conversation $id"
case "$file" in "$(dd_projects_dir)"/*/"$id.jsonl") ;; *) dd_die "unexpected path: $file" ;; esac
for other in $(jq -rM --arg id "$id" '.sessions | to_entries[] | select(.value.claude_session_id == $id) | .key' "$DEVDESK_REGISTRY" 2>/dev/null); do
if dd_server_up && dd_tmux has-session -t "=$other" 2>/dev/null; then
dd_die "session $other is using this conversation: end it first"
fi
done
dir=${file%.jsonl}
rm -f -- "$file"
[ ! -d "$dir" ] || rm -rf -- "$dir"
# shellcheck disable=SC2016 # jq variables
dd_registry_update '.sessions |= with_entries(if .value.claude_session_id == $id then .value |= (del(.claude_session_id) | del(.claude_source)) else . end)' \
--arg id "$id"
jq -cMn --arg id "$id" '{ok: true, deleted: $id}'
}
+12
View File
@@ -183,6 +183,18 @@ dd_registry_set_dir() {
--arg n "$1" --arg d "$2" --arg t "$(date -Is)"
}
# Remote Control for session NAME (devdesk#61): its own choice if it made one,
# else the default set in Settings (state settings.json), else
# DEVDESK_REMOTE_CONTROL from the env files. Prints 1 or 0.
dd_rc_wanted() {
local own def
# (not dd_registry_get: its `// empty` would turn false into "unset")
own=$(jq -Mr --arg n "$1" '.sessions[$n].remote_control | if . == null then empty else tostring end' "$DEVDESK_REGISTRY" 2>/dev/null)
case $own in true) echo 1; return ;; false) echo 0; return ;; esac
def=$(jq -rM '.remote_control_default // empty' "$DEVDESK_STATE_DIR/settings.json" 2>/dev/null)
case $def in true) echo 1 ;; false) echo 0 ;; *) [ "${DEVDESK_REMOTE_CONTROL:-0}" = 1 ] && echo 1 || echo 0 ;; esac
}
dd_registry_set_claude() {
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {claude_session_id: $id, claude_source: $src, updated: $t})' \
--arg n "$1" --arg id "$2" --arg src "$3" --arg t "$(date -Is)"
+71 -1
View File
@@ -25,7 +25,9 @@ setup() {
#!/bin/bash
printf '%s\n' "$*" >>"$FAKE_LOG/$DEVDESK_SESSION"
[ -z "${FAKE_EXIT:-}" ] || exit "$FAKE_EXIT"
exec sleep 300
# Keeps its arguments in /proc/PID/cmdline (Remote Control is read from there)
# and ends on SIGTERM, like Claude.
exec bash -c 'sleep 300 & wait' fake-claude "$@"
EOF
chmod +x "$CLAUDE_BIN"
}
@@ -1338,3 +1340,71 @@ last_json() { tail -n1 <<<"$output"; }
run devdesk attach --fs-grep alpha - ''
[ "$status" -ne 0 ]
}
@test "Remote Control is per session: Settings default, live state, restart when idle (#61)" {
start_server
devdesk new alpha
run claude_args alpha
[[ $output != *remote-control* ]]
run devdesk attach --list
[ "$(jq -c '[.remote_control_default, (.sessions[] | select(.name=="alpha") | .remote_control, .rc_active)]' <<<"$output")" = '[false,false,false]' ]
# Default on for new sessions
run devdesk attach --settings remote-control-default on
[ "$(jq -r .remote_control_default <<<"$output")" = true ]
devdesk new beta
run claude_args beta
[[ $output == *"--remote-control devdesk-beta"* ]]
for _ in $(seq 1 30); do
[ "$(devdesk attach --list | jq -r '.sessions[] | select(.name=="beta") | .rc_active')" = true ] && break
sleep 0.1
done
[ "$(devdesk attach --list | jq -r '.sessions[] | select(.name=="beta") | .rc_active')" = true ]
# alpha (idle): switching restarts Claude with the flag, no exit menu
run devdesk attach --remote-control alpha on
[ "$(jq -r .applied <<<"$output")" = now ]
for _ in $(seq 1 50); do [ "$(wc -l <"$FAKE_LOG/alpha")" -ge 2 ] && break; sleep 0.1; done
[[ "$(tail -n1 "$FAKE_LOG/alpha")" == *"--remote-control devdesk-alpha"* ]]
[ ! -e "$DEVDESK_STATE_DIR/restart/alpha" ]
# working: saved for the next start, Claude untouched
mkdir -p "$DEVDESK_STATE_DIR/state"
printf '{"state":"working","since":1}' >"$DEVDESK_STATE_DIR/state/alpha.json"
run devdesk attach --remote-control alpha off
[ "$(jq -r .applied <<<"$output")" = next-start ]
sleep 0.5
[ "$(wc -l <"$FAKE_LOG/alpha")" -eq 2 ]
[ "$(jq -r .sessions.alpha.remote_control "$DEVDESK_STATE_DIR/registry.json")" = false ]
# an explicit off beats the default on
[ "$(devdesk attach --list | jq -r '.sessions[] | select(.name=="alpha") | .remote_control')" = false ]
run devdesk attach --remote-control nope on
[ "$status" -ne 0 ]
run devdesk attach --remote-control alpha maybe
[ "$status" -ne 0 ]
}
@test "delete-convo removes a conversation's local files, never one in use (#61)" {
start_server
export CLAUDE_CONFIG_DIR="$TMP/claude"
id=11111111-2222-3333-4444-555555555555
other=99999999-2222-3333-4444-555555555555
d="$CLAUDE_CONFIG_DIR/projects/-x"
mkdir -p "$d/$id/subagents" "$d/$other"
echo '{}' >"$d/$id.jsonl" && echo '{}' >"$d/$id/subagents/a.jsonl" && echo '{}' >"$d/$other.jsonl"
devdesk new alpha
jq --arg id "$id" '.sessions.alpha.claude_session_id = $id | .sessions.dormant = {dir: "/tmp", claude_session_id: $id}' \
"$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json"
run devdesk attach --delete-convo "$id"
[ "$status" -ne 0 ] && [[ $output == *"session alpha is using"* ]] && [ -f "$d/$id.jsonl" ]
devdesk end alpha
run devdesk attach --delete-convo "$id"
[ "$status" -eq 0 ]
[ ! -e "$d/$id.jsonl" ] && [ ! -e "$d/$id" ] && [ -f "$d/$other.jsonl" ]
[ "$(jq -r '.sessions.dormant.claude_session_id // "none"' "$DEVDESK_STATE_DIR/registry.json")" = none ]
run devdesk attach --delete-convo "$id"
[ "$status" -ne 0 ]
run devdesk attach --delete-convo "../../etc/passwd"
[ "$status" -ne 0 ]
}
+1 -10
View File
@@ -53,16 +53,7 @@
<div class="dd-more">
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm dd-more-toggle" id="more-toggle" aria-haspopup="menu" aria-expanded="false" title="Settings" aria-label="Settings">⋯</button>
<div class="dd-menu dd-more-menu" id="more-menu" role="menu" hidden>
<div class="dd-menu-row">
<span>Text size</span>
<span class="dd-menu-inline">
<button type="button" class="dd-icon-btn" id="font-down" aria-label="Smaller text">A−</button>
<span id="font-size">14</span>
<button type="button" class="dd-icon-btn" id="font-up" aria-label="Larger text">A+</button>
</span>
</div>
<button type="button" class="dd-menu-item" id="theme-menu" role="menuitem">Light mode</button>
<button type="button" class="dd-menu-item" id="notify-toggle" role="menuitem">Desktop notifications</button>
<button type="button" class="dd-menu-item" id="settings-menu" role="menuitem">Settings…</button>
<div class="dd-menu-row dd-usage-row" id="usage-row" hidden><span>Plan usage</span><span class="dd-usage" id="usage-menu"></span></div>
<button type="button" class="dd-menu-item" id="history-menu" role="menuitem">Search past conversations</button>
<button type="button" class="dd-menu-item" id="palette-menu" role="menuitem">Command palette</button>
+23 -3
View File
@@ -6,7 +6,7 @@ import { runCommand, sessionAction } from './connection.js';
import { parseListOutput, slugify } from './protocol.js';
import { el } from './dom.js';
import { ago } from './sessions.js';
import { openDialog, toast } from './ui.js';
import { confirmDialog, openDialog, toast } from './ui.js';
const ROLE = { you: 'You', claude: 'Claude', command: 'Ran', output: 'Output' };
@@ -82,9 +82,28 @@ export function openHistory(hooks) {
el('button', {
type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm',
onclick: () => navigator.clipboard?.writeText(`claude --resume ${r.id}`).then(() => toast.success('Copied the resume command')),
}, 'Copy command'));
}, 'Copy command'),
live ? null : el('button', {
type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm dd-danger-text', title: 'Delete this conversation from this machine for good',
onclick: async () => {
const ok = await confirmDialog({
title: 'Delete this conversation?',
text: `“${r.title.slice(0, 80)}” (${day(r.started)}) is removed from this machine for good: its transcript and subagent files in ~/.claude/projects. It can't be resumed or found again. If its session had Remote Control on, a copy is also at claude.ai/code: delete it there too.`,
confirmLabel: 'Delete for good',
danger: true,
});
if (!ok) return;
try {
await sessionAction('delete-convo', r.id);
item.remove();
toast.success('Conversation deleted from this machine', 8000, [{ label: 'claude.ai/code', run: () => window.open('https://claude.ai/code', '_blank', 'noopener') }]);
} catch (error) {
toast.error(error.message);
}
},
}, 'Delete…'));
fill();
return el('div', { class: 'dd-history-item' },
const item = el('div', { class: 'dd-history-item' },
el('div', { class: 'dd-history-title' }, r.title),
el('div', { class: 'dd-history-meta' },
[r.cwd, day(r.started), `updated ${ago(r.updated)}${ago(r.updated) === 'now' ? '' : ' ago'}`, `${r.hits} match${r.hits === 1 ? '' : 'es'}`, live ? `open in ${live}` : '']
@@ -92,6 +111,7 @@ export function openHistory(hooks) {
...r.matches.map((m) => el('div', { class: `dd-history-snippet is-${m.role}` },
el('span', { class: 'dd-history-role' }, ROLE[m.role] ?? m.role), el('span', {}, ...highlight(m.snippet, data.query)))),
actions);
return item;
}));
};
+39 -30
View File
@@ -1,6 +1,6 @@
/* global __DEVDESK_VERSION__ */
import { THEMES, createTerminal, loadFonts } from './term.js';
import { getTheme, initTheme, onThemeChange, toggleTheme } from './theme.js';
import { getTheme, initTheme, onThemeChange, setTheme, toggleTheme } from './theme.js';
import {
TtydConnection, fetchAlerts, fetchRepo, fetchToken, investigateAlert, lastReply, replyTo, sessionAction, uploadFile,
AuthExpiredError, UnauthorizedError,
@@ -16,6 +16,7 @@ import { Inbox } from './inbox.js';
import { FilesPane } from './files.js';
import { registerPathLinks } from './links.js';
import { openHistory } from './history.js';
import { applyUiScale, openSettings } from './settings.js';
import { el } from './dom.js';
import * as notify from './notify.js';
@@ -55,7 +56,6 @@ const ui = {
overlayActions: $('overlay-actions'),
moreToggle: $('more-toggle'),
moreMenu: $('more-menu'),
notifyButton: $('notify-toggle'),
sidebarButton: $('sidebar-toggle'),
backdrop: $('backdrop'),
};
@@ -610,10 +610,41 @@ function setFontSize(size) {
term.options.fontSize = clamped;
store.set('fontSize', clamped);
document.documentElement.style.setProperty('--dd-editor-font', `${clamped - 1}px`);
$('font-size').textContent = String(clamped);
fit.fit();
}
// Settings (devdesk#61): appearance, notifications, Remote Control per session.
function showSettings() {
closeMenus();
openSettings({
getTheme,
setTheme,
fontSize: () => term.options.fontSize,
setFontSize,
uiScale: () => Number(store.get('uiScale', 1)) || 1,
setUiScale: (scale) => {
store.set('uiScale', scale);
applyUiScale(scale);
requestAnimationFrame(() => fit.fit());
},
notifications: () => {
const current = notify.notificationsState();
return {
label: {
unsupported: 'Not supported', denied: 'Blocked by the browser', default: 'Turn on',
granted: notify.isMuted() ? 'Muted: unmute' : 'On: mute',
}[current],
disabled: current === 'unsupported' || current === 'denied',
run: toggleNotifications,
};
},
sessions: () => sessionsPanel?.data,
current: () => state.session,
refresh: () => sessionsPanel.reload(),
openHistory: showHistory,
});
}
async function toggleNotifications() {
const current = notify.notificationsState();
if (current === 'default') {
@@ -850,12 +881,13 @@ function buildCommands() {
add({ id: 'font-up', group: 'View', icon: 'A+', label: 'Larger text', keywords: 'font zoom', run: () => setFontSize(term.options.fontSize + 1) });
add({ id: 'font-down', group: 'View', icon: 'A−', label: 'Smaller text', keywords: 'font zoom', run: () => setFontSize(term.options.fontSize - 1) });
add({ id: 'font-reset', group: 'View', icon: 'A', label: 'Reset text size', keywords: 'font zoom', run: () => setFontSize(14) });
add({ id: 'settings', group: 'View', icon: '⚙', label: 'Settings…', keywords: 'preferences text size font theme remote control privacy notifications', run: showSettings });
add({ id: 'fullscreen', group: 'View', icon: '⛶', label: document.fullscreenElement ? 'Exit fullscreen' : 'Fullscreen', run: toggleFullscreen });
if (notifyState !== 'unsupported') {
add({
id: 'notify', group: 'View', icon: '🔔', keywords: 'notifications alerts bell',
label: { default: 'Enable desktop notifications', denied: 'Desktop notifications (blocked)', granted: notify.isMuted() ? 'Unmute desktop notifications' : 'Mute desktop notifications' }[notifyState],
run: () => toggleNotifications().then(renderNotifyButton),
run: () => toggleNotifications(),
});
}
@@ -968,17 +1000,6 @@ async function toggleFullscreen() {
navigator.keyboard?.lock?.().catch(() => {});
}
function renderNotifyButton() {
const current = notify.notificationsState();
const labels = {
unsupported: 'Desktop notifications: unsupported',
denied: 'Desktop notifications: blocked by the browser',
default: 'Enable desktop notifications',
granted: notify.isMuted() ? 'Desktop notifications: muted (unmute)' : 'Desktop notifications: on (mute)',
};
ui.notifyButton.textContent = labels[current];
ui.notifyButton.disabled = current === 'unsupported' || current === 'denied';
}
function setSidebar(open) {
if (open && files?.isOpen) files.close();
@@ -1028,12 +1049,7 @@ function wireKeybar() {
}
function wireChrome() {
$('font-down').addEventListener('click', () => setFontSize(term.options.fontSize - 1));
$('font-up').addEventListener('click', () => setFontSize(term.options.fontSize + 1));
ui.notifyButton.addEventListener('click', async () => {
await toggleNotifications();
renderNotifyButton();
});
$('settings-menu').addEventListener('click', showSettings);
$('fullscreen-toggle').addEventListener('click', () => {
closeMenus();
toggleFullscreen();
@@ -1095,14 +1111,7 @@ function wireChrome() {
else if (!dialogOpen()) palette.open();
}
}, true);
wireMenu(ui.moreToggle, ui.moreMenu, () => {
renderNotifyButton();
$('theme-menu').textContent = getTheme() === 'light' ? 'Dark mode' : 'Light mode';
});
$('theme-menu').addEventListener('click', () => {
closeMenus();
toggleTheme();
});
wireMenu(ui.moreToggle, ui.moreMenu, () => {});
inbox = new Inbox({
button: $('inbox-toggle'),
badge: $('inbox-badge'),
@@ -1233,7 +1242,7 @@ async function boot() {
initTheme();
$('version').textContent = `devdesk ${__DEVDESK_VERSION__}`;
const fontSize = Math.min(24, Math.max(10, Number(store.get('fontSize', 14)) || 14));
$('font-size').textContent = String(fontSize);
applyUiScale(Number(store.get('uiScale', 1)) || 1);
await loadFonts(fontSize);
({ term, fit } = createTerminal($('terminal'), {
+2
View File
@@ -321,6 +321,8 @@ export class SessionsPanel {
el('button', { class: 'dd-session-open', type: 'button', title, onclick: () => this.hooks.onOpen(s.name) },
el('span', { class: `lt-dot ${status.dot}` }),
el('span', { class: 'dd-session-name' }, s.name,
// Remote Control on: synced to claude.ai and the Claude app (devdesk#61).
s.rc_active ? el('span', { class: 'dd-tag dd-rc', title: 'Remote Control is on: this session syncs to claude.ai and the Claude app. Change it in Settings.' }, 'RC') : null,
s.worktree ? el('span', { class: 'dd-tag dd-wt', title: `Own worktree on ${s.worktree.branch}\n${s.worktree.path}` }, `⑂ ${s.worktree.branch.replace(/^devdesk\//, '')}`) : null,
s.tags?.length ? el('span', { class: 'dd-tags', title: 'Hosts this session worked on' }, ...s.tags.slice(0, 3).map((t) => el('span', { class: 'dd-tag' }, t))) : null,
// The pull request of this branch (devdesk#25)
+134
View File
@@ -0,0 +1,134 @@
// Settings (devdesk#61): the things you set once and leave, in one window
// instead of the ⋯ menu. Appearance (theme, terminal text size, interface size)
// and notifications are per browser; Remote Control is per session, with a
// default for new sessions, kept on the server.
import { el } from './dom.js';
import { openDialog, toast } from './ui.js';
import { sessionAction } from './connection.js';
const SCALES = [['Small', 0.9], ['Normal', 1], ['Large', 1.12], ['Larger', 1.25]];
// Interface size: the page is sized in rem off the root font size (14px).
export function applyUiScale(scale) {
document.documentElement.style.fontSize = scale === 1 ? '' : `${Math.round(14 * scale * 10) / 10}px`;
}
function segmented(label, options, current, onPick) {
const buttons = options.map(([text, value]) => el('button', {
type: 'button', class: 'dd-seg-btn', 'aria-pressed': String(value === current),
onclick: (event) => {
for (const b of buttons) b.setAttribute('aria-pressed', 'false');
event.currentTarget.setAttribute('aria-pressed', 'true');
onPick(value);
},
}, text));
return el('div', { class: 'dd-setting' }, el('span', { class: 'dd-setting-label' }, label), el('span', { class: 'dd-seg', role: 'group', 'aria-label': label }, ...buttons));
}
function toggle(label, checked, onChange, hint) {
const input = el('input', { type: 'checkbox', role: 'switch', checked: checked ? true : null });
input.addEventListener('change', () => onChange(input.checked, input));
return el('label', { class: 'dd-setting dd-switch-row' },
el('span', { class: 'dd-setting-label' }, label, hint ? el('span', { class: 'dd-setting-hint' }, hint) : null),
el('span', { class: 'dd-switch' }, input, el('span', { class: 'dd-switch-track', 'aria-hidden': 'true' })));
}
// hooks: getTheme(), setTheme(t), fontSize(), setFontSize(n), uiScale(), setUiScale(s),
// notifications() -> { label, disabled, run }, sessions() -> list data, current(),
// refresh(), openHistory()
export async function openSettings(hooks) {
const data = hooks.sessions();
const current = hooks.current();
const size = el('span', { class: 'dd-setting-value' }, String(hooks.fontSize()));
const setSize = (n) => {
hooks.setFontSize(n);
size.textContent = String(hooks.fontSize());
};
const notif = hooks.notifications();
const notifyButton = el('button', { type: 'button', class: 'lt-btn lt-btn-sm', disabled: notif.disabled ? true : null }, notif.label);
notifyButton.addEventListener('click', async () => {
await hooks.notifications().run();
const next = hooks.notifications();
notifyButton.textContent = next.label;
notifyButton.disabled = next.disabled;
});
// Remote Control, per session.
const running = (data?.sessions ?? []).filter((s) => s.running).sort((a, b) => (a.name === current ? -1 : b.name === current ? 1 : a.name.localeCompare(b.name)));
const rcRows = running.map((s) => toggle(s.name, s.remote_control, async (on, input) => {
input.disabled = true;
try {
const result = await sessionAction('remote-control', s.name, on ? 'on' : 'off');
const what = `Remote Control ${on ? 'on' : 'off'}`;
if (result?.applied === 'now') toast.success(`${s.name}: restarting Claude with ${what} (same conversation)`, 6000);
else if (result?.applied === 'next-start') toast.info(`${s.name} is busy: ${what} when Claude next starts`, 7000);
else toast.success(`${s.name}: ${what}`);
hooks.refresh();
} catch (error) {
input.checked = !on;
toast.error(`${s.name}: ${error.message}`);
} finally {
input.disabled = false;
}
}, s.rc_active ? 'syncing to claude.ai now' : s.remote_control ? 'on at the next Claude start' : ''));
const defaultRow = toggle('New sessions', Boolean(data?.remote_control_default), async (on, input) => {
input.disabled = true;
try {
await sessionAction('settings', 'remote-control-default', on ? 'on' : 'off');
toast.success(`New sessions: Remote Control ${on ? 'on' : 'off'}`);
hooks.refresh();
} catch (error) {
input.checked = !on;
toast.error(error.message);
} finally {
input.disabled = false;
}
}, 'sessions you create from now on');
const link = (href, text) => el('a', { href, target: '_blank', rel: 'noopener' }, text);
const body = el('div', { class: 'dd-settings' },
el('section', { class: 'dd-settings-group' },
el('h3', {}, 'Appearance'),
segmented('Theme', [['Dark', 'dark'], ['Light', 'light']], hooks.getTheme(), (t) => hooks.setTheme(t)),
el('div', { class: 'dd-setting' },
el('span', { class: 'dd-setting-label' }, 'Terminal text size'),
el('span', { class: 'dd-stepper' },
el('button', { type: 'button', class: 'dd-icon-btn', 'aria-label': 'Smaller terminal text', onclick: () => setSize(hooks.fontSize() - 1) }, 'A−'),
size,
el('button', { type: 'button', class: 'dd-icon-btn', 'aria-label': 'Larger terminal text', onclick: () => setSize(hooks.fontSize() + 1) }, 'A+'),
el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: () => setSize(14) }, 'Reset'))),
segmented('Interface size', SCALES, hooks.uiScale(), (s) => hooks.setUiScale(s)),
el('p', { class: 'dd-setting-note' }, 'Appearance is remembered in this browser.')),
el('section', { class: 'dd-settings-group' },
el('h3', {}, 'Notifications'),
el('div', { class: 'dd-setting' }, el('span', { class: 'dd-setting-label' }, 'Desktop notifications', el('span', { class: 'dd-setting-hint' }, 'when another session needs you')), notifyButton)),
el('section', { class: 'dd-settings-group' },
el('h3', {}, 'Remote Control & privacy'),
el('p', { class: 'dd-setting-note' },
'With Remote Control on, a session also appears in the Claude app and at claude.ai/code, and Anthropic stores its transcript to keep your devices in sync (30 days, or 5 years if "Help improve Claude" is on in ',
link('https://claude.ai/settings/data-privacy-controls', 'your privacy settings'), '). Turning it off stops syncing from then on; what was already synced stays until you delete it at ',
link('https://claude.ai/code', 'claude.ai/code'), '. Switching a running session restarts its Claude on the same conversation.'),
defaultRow,
running.length ? el('div', { class: 'dd-setting-sub' }, 'Running sessions') : null,
...rcRows),
el('section', { class: 'dd-settings-group' },
el('h3', {}, 'Conversations'),
el('p', { class: 'dd-setting-note' }, 'Every conversation is also kept on this machine in ~/.claude/projects (plaintext, 30 days). Delete one for good from Search past conversations.'),
el('div', { class: 'dd-setting' }, el('span', { class: 'dd-setting-label' }, 'Find or delete a conversation'),
el('button', { type: 'button', class: 'lt-btn lt-btn-sm', 'data-close': 'history' }, 'Search past conversations…'))));
const choice = await openDialog({
title: 'Settings',
className: 'dd-settings-dialog',
body,
onOpen: () => {
body.querySelector('[data-close="history"]').addEventListener('click', () => {
body.closest('.lt-modal-overlay, .lt-modal')?.querySelector('[aria-label="Close"], .lt-modal-close')?.click();
setTimeout(() => hooks.openHistory(), 50);
});
return body.querySelector('button, input');
},
actions: [{ label: 'Done', primary: true }],
});
return choice;
}
+29
View File
@@ -1144,3 +1144,32 @@ body.dd-touch .dd-footer-usage { display: none; }
.dd-search-ln { flex: none; min-width: 2.5em; color: var(--text-muted); text-align: right; }
.dd-search-text { overflow: hidden; text-overflow: ellipsis; }
.dd-search-text mark { background: var(--accent-amber-dim); color: var(--accent-amber); outline: 1px solid color-mix(in srgb, var(--accent-amber) 50%, transparent); }
/* Settings (devdesk#61) */
.dd-settings-dialog .lt-modal { width: min(620px, calc(100vw - 24px)); max-height: calc(100vh - 40px); }
.dd-settings-dialog .dd-dialog-body { max-height: calc(100vh - 170px); overflow-y: auto; }
.dd-settings { display: flex; flex-direction: column; gap: 18px; }
.dd-settings-group h3 { margin: 0 0 8px; color: var(--accent-orange); font-family: var(--font-mono); font-size: 0.68rem; letter-spacing: 0.12em; text-transform: uppercase; }
.dd-setting { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 34px; padding: 4px 0; border-bottom: 1px solid var(--border-dim); }
.dd-setting-label { display: flex; flex-direction: column; color: var(--text-primary); font-size: 0.8rem; }
.dd-setting-hint { color: var(--text-muted); font-size: 0.68rem; }
.dd-setting-value { min-width: 2ch; text-align: center; font-family: var(--font-mono); }
.dd-setting-note { margin: 4px 0 8px; color: var(--text-secondary); font-size: 0.72rem; line-height: 1.5; }
.dd-setting-note a { color: var(--accent-cyan); }
.dd-setting-sub { margin-top: 10px; color: var(--text-muted); font-family: var(--font-mono); font-size: 0.64rem; letter-spacing: 0.08em; text-transform: uppercase; }
.dd-stepper { display: inline-flex; align-items: center; gap: 6px; }
.dd-seg { display: inline-flex; border: 1px solid var(--border-color); }
.dd-seg-btn { padding: 4px 10px; border: 0; border-right: 1px solid var(--border-color); background: none; color: var(--text-secondary); font-family: var(--font-mono); font-size: 0.72rem; cursor: pointer; }
.dd-seg-btn:last-child { border-right: 0; }
.dd-seg-btn[aria-pressed="true"] { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
.dd-switch-row { cursor: pointer; }
.dd-switch { position: relative; flex: none; width: 36px; height: 20px; }
.dd-switch input { position: absolute; inset: 0; margin: 0; opacity: 0; cursor: pointer; }
.dd-switch-track { position: absolute; inset: 0; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-tertiary); transition: background 0.15s; }
.dd-switch-track::after { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: var(--text-muted); transition: transform 0.15s, background 0.15s; }
.dd-switch input:checked + .dd-switch-track { background: var(--accent-cyan-dim); border-color: var(--accent-cyan); }
.dd-switch input:checked + .dd-switch-track::after { transform: translateX(16px); background: var(--accent-cyan); }
.dd-switch input:focus-visible + .dd-switch-track { outline: 1px dashed var(--accent-cyan); outline-offset: 2px; }
.dd-switch input:disabled + .dd-switch-track { opacity: 0.5; }
.dd-rc { color: var(--accent-purple); border-color: color-mix(in srgb, var(--accent-purple) 50%, transparent); }
.dd-danger-text { color: var(--accent-red) !important; }