Compare commits

...
4 Commits
Author SHA1 Message Date
jaredandClaude Opus 5.5 a2edec966a feat: v0.10.0 quieter Matrix, worktrees, usage pace, repo diffs, copy reply, turn timer, compact
build-deb / Notify on failure (push) Successful in 4s
build-deb / Lint, test, package (push) Failing after 2m22s
build-deb / Deploy to CT 126 (push) Skipped
- Matrix "needs you": ignore Claude's idle reminder (no red, no post); post
  only when no tab is in view, after 60 s, once per session per 10 min, and
  say what is asked. Closes #50
- devdesk new NAME PROJECT --worktree: own checkout on devdesk/NAME,
  cleaned up on end when clean/merged. Closes #44
- Usage meters: reset time, pace warning; Matrix ping past 90% when away.
  Closes #45
- Repo panel lists changed files; click opens the diff. Closes #46
- Copy Claude's last reply (--last-reply). Closes #47
- Turn timer in chip, title and cards. Closes #48
- Compact button at 80% context when idle. Closes #49

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
2026-10-11 23:28:11 -04:00
jaredandClaude Opus 5.5 bf8821f3f9 feat: v0.9.5 context in tokens as well as percent
build-deb / Lint, test, package (push) Successful in 2m17s
build-deb / Deploy to CT 126 (push) Successful in 8s
build-deb / Notify on failure (push) Skipped
Claude's footer reads "ctx 34% 346k/1M"; the Sessions panel and the
session switcher use the same label via ctxLabel(). Closes #43

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
2026-10-11 23:07:04 -04:00
jaredandClaude Opus 5.5 fe4df10210 fix: v0.9.4 inbox badge clears when seen
build-deb / Lint, test, package (push) Successful in 2m23s
build-deb / Deploy to CT 126 (push) Failing after 3m15s
build-deb / Notify on failure (push) Successful in 3s
Opening the bell marks its events seen; viewing a session (or returning
to the tab) reads that session's events. The badge now drops to what is
live instead of climbing with every background "finished". Closes #42

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
2026-10-11 22:45:46 -04:00
jaredandClaude Opus 5.5 963d2f5413 fix: v0.9.3 plan usage instead of API-price cost; larger footer
build-deb / Lint, test, package (push) Successful in 2m15s
build-deb / Deploy to CT 126 (push) Successful in 7s
build-deb / Notify on failure (push) Skipped
- Footer shows "5h N%" when Claude sends rate_limits (Pro/Max); the
  dollar figure is an API-price estimate, so it is kept only for API-key
  billing. Sessions tooltip follows suit. Closes #40
- Bottom row: 40px, larger hints, bold 1rem usage meters with wider bars;
  narrow windows drop hints instead of clipping the meters. Closes #41
- bats setup unsets inherited DevDesk env so tests pass inside DevDesk

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
2026-10-11 22:07:39 -04:00
18 changed files with 673 additions and 90 deletions
+9 -7
View File
@@ -31,15 +31,17 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
- With no `?arg=`, DevDesk reattaches the session you used last in this browser (or the most recently active one). The fzf picker, listing running (●), dormant (○) and new (+) sessions, is under the session chip's menu.
- **Session chip** (header): click to switch sessions; its dot shows Claude's state.
- **+ New** in the Sessions panel takes any name plus an optional project folder.
- **Own git worktree:** for a git project, tick **own git worktree** (or `devdesk new NAME PROJECT --worktree`). The session gets its own checkout in `$ROOT/.worktrees/PROJECT--NAME` on branch `devdesk/NAME`, so two Claudes on one repo never edit the same files. Ending it removes the worktree only if nothing is uncommitted, and the branch only if it's merged.
- **Rename / end:** the ✎ and × buttons on each session in the panel. Renaming keeps the session, its running Claude and its recorded conversation. Ending stops Claude; the conversation stays in Claude's history.
- **Windows:** each session has a `claude` window (0) and a `shell` window (1).
- **Conversation tracking:** a Claude `SessionStart` hook records each session's conversation ID in `/var/lib/devdesk/registry.json`. After a reboot the session resumes *that* conversation, even when other Claude instances, such as code-server, use the same directory.
- **Session status:** Claude hooks (`devdesk-state-hook`) mark each session **working**, **waiting on you** (with Claude's message) or **idle**.
- **Session status:** Claude hooks (`devdesk-state-hook`) mark each session **working**, **waiting on you** (with Claude's message) or **idle**. Claude's idle reminder a minute after a turn ends doesn't count as waiting. While Claude works, the session chip and the tab title show how long the turn has run (`⏳ 3m`).
- **Session buttons:** ⧉ copies Claude's whole last reply as Markdown. ⇲ **Compact** appears when the context is 80 % full and Claude is idle, and sends `/compact`.
- The sidebar shows it with context % and a context bar; the session chip's dot shows the current session's state.
- A "… needs you" chip jumps to other sessions that are waiting, and a desktop notification fires when one starts waiting.
- The **🔔 inbox** lists what needs you (waiting sessions, firing Grafana alerts) and what happened while you were away (a background session finished or started waiting, CI passed or failed); its badge counts them.
- **Answer from the inbox:** a session waiting on a permission shows the exact command/file and **Allow / Deny** (the key is pressed only if that prompt is still on screen); other waits and finished sessions offer **Peek & reply**: the last exchange and a reply box.
- **Matrix "Claude needs you":** when a session has waited for `DEVDESK_MATRIX_DELAY` seconds (30) and no browser tab or SSH client is attached to it, `devdesk-notify-matrix` posts one message per wait, with a link, to the hookshot webhook URL in `/etc/devdesk/matrix-webhook` (mode 600; no file = off).
- **Matrix "Claude needs you":** when a session has waited on a real request (a permission prompt or a question) for `DEVDESK_MATRIX_DELAY` seconds (60) and no DevDesk tab has been in view for about 2 minutes, `devdesk-notify-matrix` posts what Claude asks ("alpha: Claude wants to run Bash (Restart nginx)", the command, a link). The post goes to the hookshot webhook URL in `/etc/devdesk/matrix-webhook` (mode 600; no file = off). Each session posts at most once per `DEVDESK_MATRIX_QUIET` seconds (600).
- **File & image drop:** drag files onto the terminal or paste a screenshot.
- They're uploaded through ttyd to `/var/lib/devdesk/uploads/<session>/` (25 MB max, pruned after 7 days), and the path is pasted into Claude.
- For images, Claude attaches them itself.
@@ -48,18 +50,18 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
- Guests are matched by the alert's `instance` IP (or host name), else by name in the job or alert ("by name"). Aggregate rules with no labels (`count(up == 0)`) get a hint to run the inner query instead.
- **`devdesk guests [ID|IP|NAME]`** lists Proxmox CTs/VMs with the node each runs on now, status, HA state and IPs. Claude is told to use it before acting, because HA guests move. Stopped guests keep the IP they had when last seen running.
- Configure `DEVDESK_GRAFANA_URL`, plus a Viewer token in `DEVDESK_GRAFANA_TOKEN_FILE`. For the guest lookup, set `DEVDESK_PVE_NODES` to a few Proxmox nodes reachable by root SSH key. VM IPs need the QEMU guest agent.
- **Repo / CI panel:** branch, ahead/behind, uncommitted changes, HEAD's Gitea CI status and open PRs for the session's folder. When CI fails, **Ask Claude to fix CI** types a prompt to read the failed job's log.
- **Repo / CI panel:** branch, ahead/behind, uncommitted changes, HEAD's Gitea CI status and open PRs for the session's folder. The first changed files are listed; click one for its diff against HEAD. When CI fails, **Ask Claude to fix CI** types a prompt to read the failed job's log.
- **Files** (Ctrl+Shift+E), laid out like VS Code: an explorer tree (git colours, gitignored entries dimmed) and an editor with tabs (a click opens a preview tab, a double-click keeps it), **Go to file** (Ctrl+P in the pane), markdown previews (sanitized, with relative images), images, and a Changes view of uncommitted files with line counts, a **C** badge on files Claude edited, and diffs against HEAD. **@Claude** types `@path` into the prompt. Tabs and open folders are remembered per session. Paths can't leave the session folder.
- **Editing:** text files are editable; ● marks unsaved tabs; Ctrl+S (or Save) writes atomically and keeps the file's mode and owner. If the file changed on disk since you opened it (Claude edited it), saving asks: Compare (a diff of disk vs yours), Overwrite or Cancel; a change on disk while you edit shows a banner instead of replacing your text. Unsaved edits are kept as drafts in the browser, so a reload or a session switch doesn't lose them. + creates a file (folders as needed). The editor bundle (`/usr/share/devdesk/assets/editor.js`) loads over ttyd the first time and is cached by the browser.
- **Paths are links:** a file path Claude prints (`web/src/files.js`, `notes.md:12`) opens in Files when clicked, at that line if given.
- **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 (warning at 90 %); a **PR** chip on sessions whose branch has a pull request.
- **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.
- **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 and cost to `/var/lib/devdesk/context/<session>.json`. The Sessions panel shows it as a bar (cost in the tooltip), and Claude's own footer shows a one-line summary.
- **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).
- **Phone:** a key bar replaces the shortcut hints (Esc, Tab, ⇧Tab, ^C, ↑, ↓, ⏎, Claude/Shell window), and the panels open as a drawer.
- **SSH:** `devdesk attach <name>`, `devdesk new <name> [project]`, `devdesk rename <old> <new>`, `devdesk end <name>`, `devdesk ls`, `devdesk pick`, `devdesk repo <name>`, `devdesk alerts`.
- **SSH:** `devdesk attach <name>`, `devdesk new <name> [project] [--worktree]`, `devdesk rename <old> <new>`, `devdesk end <name>`, `devdesk ls`, `devdesk pick`, `devdesk repo <name>`, `devdesk alerts`.
## Keys
| Key | Action |
@@ -83,7 +85,7 @@ The **☀/◐** button switches light and dark mode (saved per browser, shared w
- `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_PUBLIC_URL`, `DEVDESK_MATRIX_WEBHOOK_FILE` and `DEVDESK_MATRIX_DELAY` for the Matrix notifications
- `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`.
+68 -12
View File
@@ -2,7 +2,9 @@
# devdesk — persistent Claude Code sessions in tmux, shared by the browser UI and SSH.
#
# devdesk attach [NAME] attach to session NAME, creating it if needed; no NAME opens the picker
# devdesk new NAME [PROJECT] create session NAME (detached) in PROJECT's folder, or the default folder
# devdesk new NAME [PROJECT] [--worktree]
# create session NAME (detached) in PROJECT's folder, or the default
# folder; --worktree gives it its own git worktree on branch devdesk/NAME
# devdesk rename OLD NEW rename a session (running or dormant)
# devdesk end NAME stop session NAME and forget it
# devdesk repo NAME git + Gitea CI status of the session's folder (JSON)
@@ -12,7 +14,8 @@
# devdesk pick interactive picker
#
# The web UI calls `devdesk attach --list | --repo NAME | --alerts | --investigate SESSION UID |
# --upload SESSION FILE BYTES | --new NAME [PROJECT] | --rename OLD NEW | --end NAME | --existing NAME`
# --upload SESSION FILE BYTES | --new NAME [PROJECT] [--worktree] | --rename OLD NEW | --end NAME |
# --last-reply NAME | --existing NAME`
# through ttyd; those print JSON.
set -euo pipefail
@@ -49,7 +52,7 @@ projects_tsv() {
# Only offer directories that attach would accept (no symlinks out of the root).
real=$(realpath -e -- "$d" 2>/dev/null) || continue
case "$real/" in "$root"/*) ;; *) continue ;; esac
printf '%s\t%s\n' "$(dd_slugify "${d##*/}")" "$d"
printf '%s\t%s\t%s\n' "$(dd_slugify "${d##*/}")" "$([ -e "$d/.git" ] && echo 1 || echo 0)" "$d"
done
}
@@ -85,15 +88,17 @@ cmd_list() {
| ($live | map(.name)) as $liveNames
| {default: $dflt, root: $root,
sessions: (
($live | map(. + {claude_session_id: ($known[.name].claude_session_id // null)}))
($live | map(. + {claude_session_id: ($known[.name].claude_session_id // null),
worktree: ($known[.name].worktree // null)}))
+ ($known | to_entries
| map(select(.key as $k | $liveNames | index($k) | not))
| map({name: .key, dir: .value.dir, running: false, attached: 0,
activity: null, windows: 0,
claude_session_id: (.value.claude_session_id // null)}))
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 // [])}),
projects: ([{name: $dflt, dir: $root}]
+ [rows($proj)[] | {name: .[0], dir: (.[1:] | join("\t"))}
+ [rows($proj)[] | {name: .[0], git: (.[1] == "1"), dir: (.[2:] | join("\t"))}
| select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}'
}
@@ -121,7 +126,12 @@ create_detached() {
}
cmd_new() {
{ [ $# -ge 1 ] && [ $# -le 2 ]; } || dd_die "usage: devdesk new NAME [PROJECT]"
local worktree=0 a args=()
for a in "$@"; do
if [ "$a" = --worktree ]; then worktree=1; else args+=("$a"); fi
done
set -- "${args[@]}"
{ [ $# -ge 1 ] && [ $# -le 2 ]; } || dd_die "usage: devdesk new NAME [PROJECT] [--worktree]"
dd_valid_name "$1" || dd_die "invalid session name: $1"
local dir
if [ $# -eq 2 ]; then
@@ -129,12 +139,49 @@ cmd_new() {
dir=$(dd_project_dir "$2") || dd_die "no project '$2' under $DEVDESK_PROJECTS_ROOT"
! session_exists "$1" || dd_die "a session named '$1' already exists"
else
[ "$worktree" = 0 ] || dd_die "--worktree needs a project"
dir=$(dd_resolve_dir "$1")
fi
[ "$worktree" = 0 ] || dir=$(dd_worktree_add "$1" "$2" "$dir")
dd_ensure_server
create_detached "$1" "$dir"
}
# A git worktree of PROJECT for session NAME (devdesk#44), so two Claudes on one
# repository never edit the same checkout. It lives in $ROOT/.worktrees (hidden
# from the project list) on branch devdesk/NAME, made from the project's HEAD or
# reused if that branch exists. Prints the folder the session starts in.
dd_worktree_add() {
local name=$1 project=$2 dir=$3 top prefix wt branch="devdesk/$1" g
top=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) || dd_die "'$project' is not a git repository"
prefix=$(git -C "$dir" rev-parse --show-prefix)
wt="$DEVDESK_PROJECTS_ROOT/.worktrees/$project--$name"
[ ! -e "$wt" ] || dd_die "$wt already exists"
mkdir -p "$DEVDESK_PROJECTS_ROOT/.worktrees"
g=(git -C "$top")
if "${g[@]}" show-ref -q --verify "refs/heads/$branch"; then
"${g[@]}" worktree add -q "$wt" "$branch" >&2 || dd_die "git worktree add failed"
else
"${g[@]}" worktree add -q -b "$branch" "$wt" HEAD >&2 || dd_die "git worktree add failed"
fi
wt=$(realpath -e -- "$wt")
# shellcheck disable=SC2016 # jq variables
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {worktree: {path: $w, repo: $r, branch: $b}})' \
--arg n "$name" --arg w "$wt" --arg r "$top" --arg b "$branch"
printf '%s\n' "$wt${prefix:+/${prefix%/}}"
}
# Ending a worktree session removes the worktree when nothing in it is uncommitted,
# and its branch only when that is merged (git branch -d). Otherwise both stay.
dd_worktree_end() {
local wt=$1 repo=$2 branch=$3
[ -d "$wt" ] || return 0
if [ -z "$(git -C "$wt" status --porcelain 2>/dev/null)" ]; then
git -C "$repo" worktree remove "$wt" 2>/dev/null || return 0
git -C "$repo" branch -d "$branch" >/dev/null 2>&1 || true
fi
}
cmd_rename() {
[ $# -eq 2 ] || dd_die "usage: devdesk rename OLD NEW"
local old=$1 new=$2 running=0
@@ -160,8 +207,14 @@ rename_locked() {
cmd_end() {
[ $# -eq 1 ] || dd_die "usage: devdesk end NAME"
dd_valid_name "$1" || dd_die "invalid session name: $1"
local wt
wt=$(jq -rM --arg n "$1" '.sessions[$n].worktree // empty | [.path, .repo, .branch] | @tsv' "$DEVDESK_REGISTRY" 2>/dev/null) || wt=""
# Forget first: killing the session SIGHUPs us when run from inside it.
dd_registry_remove "$1"
if [ -n "$wt" ]; then
IFS=$'\t' read -r -a wt <<<"$wt"
dd_worktree_end "${wt[@]}"
fi
rm -rf "${DEVDESK_STATE_DIR:?}/uploads/$1"
if dd_server_up; then
dd_tmux kill-session -t "=$1" 2>/dev/null || true
@@ -196,7 +249,7 @@ gitea_slug() {
cmd_repo() {
[ $# -eq 1 ] || dd_die "usage: devdesk repo NAME"
dd_valid_name "$1" || dd_die "invalid session name: $1"
local dir g branch sha="" dirty ahead=null behind=null upstream=false remote slug="" ci=null prs=null
local dir g branch sha="" dirty changes ahead=null behind=null upstream=false remote slug="" ci=null prs=null
dir=$(dd_resolve_dir "$1")
# --no-optional-locks: never take .git/index.lock (Claude's own git would collide).
# No safe.directory override: repos owned by other users stay untrusted.
@@ -208,6 +261,9 @@ cmd_repo() {
branch=$("${g[@]}" symbolic-ref --short -q HEAD || echo "(detached)")
sha=$("${g[@]}" rev-parse -q --verify HEAD 2>/dev/null || true)
dirty=$("${g[@]}" status --porcelain 2>/dev/null | wc -l)
# The first changed files, for the panel to open as diffs (devdesk#46).
changes='[]'
[ "$dirty" = 0 ] || changes=$(cmd_git_status "$1" 2>/dev/null | jq -cM '.files[:8] // []' 2>/dev/null) || changes='[]'
if "${g[@]}" rev-parse -q --verify '@{u}' >/dev/null 2>&1; then
upstream=true
read -r behind ahead < <("${g[@]}" rev-list --left-right --count '@{u}...HEAD')
@@ -222,10 +278,10 @@ cmd_repo() {
prs=$(gitea_get "/repos/$slug/pulls?state=open&limit=50" | jq -M 'length' 2>/dev/null) || prs=null
fi
jq -cMn --arg s "$1" --arg dir "$dir" --arg branch "$branch" --arg sha "${sha:0:7}" \
--argjson dirty "$dirty" --argjson ahead "$ahead" --argjson behind "$behind" \
--argjson dirty "$dirty" --argjson changes "${changes:-[]}" --argjson ahead "$ahead" --argjson behind "$behind" \
--argjson upstream "$upstream" --arg remote "$remote" --arg slug "$slug" \
--arg web "$DEVDESK_GITEA_URL" --argjson ci "${ci:-null}" --argjson prs "${prs:-null}" '
{session: $s, dir: $dir, git: true, branch: $branch, sha: $sha, dirty: $dirty,
{session: $s, dir: $dir, git: true, branch: $branch, sha: $sha, dirty: $dirty, changes: $changes,
ahead: $ahead, behind: $behind, upstream: $upstream, remote: $remote,
repo: (if $slug == "" then null else $slug end),
url: (if $slug == "" then null else "\($web)/\($slug)" end),
@@ -310,10 +366,10 @@ cmd_attach() {
dd_linger
return
;;
--peek | --search)
--peek | --search | --last-reply)
local action=${1#--}
shift
"cmd_$action" "$@"
"cmd_${action//-/_}" "$@"
dd_linger
return
;;
+56
View File
@@ -1,3 +1,59 @@
devdesk (0.10.0) bookworm; urgency=medium
* Matrix "Claude needs you" is quieter and says what is asked (#50):
- Claude's idle reminder after a turn no longer counts as a wait: no red
session, no inbox entry and no Matrix post.
- Posts only when no DevDesk tab has been in view for ~2 minutes, after
60 s, and at most once per session per 10 minutes
(DEVDESK_MATRIX_QUIET).
- The message names the tool, its description and the command.
* Own git worktree per session (#44): `devdesk new NAME PROJECT
--worktree` or a checkbox in the New session form. It uses branch
devdesk/NAME in $ROOT/.worktrees. Ending the session removes the
worktree if it is clean, and the branch if it is merged.
* Plan meters show when each window resets. If the pace so far would use
a window up before then, they warn instead. Matrix gets one message per
5-hour window past DEVDESK_USAGE_WARN (90%) when you're away (#45).
* Repo panel lists the changed files; a click opens the diff (#46).
* Copy Claude's last reply as Markdown: a session button and a palette
entry, backed by `--last-reply` (#47).
* Turn timer in the session chip, the tab title and session cards (#48).
* Compact button when the context is 80% full and Claude is idle (#49).
-- Jared Vititoe <jjvititoe1@gmail.com> Sun, 11 Oct 2026 23:30:00 -0400
devdesk (0.9.5) bookworm; urgency=medium
* Context use is shown in tokens as well as percent (#43): Claude's
footer shows "ctx 34% 346k/1M", and the Sessions panel and session
switcher show "ctx 34% · 346k/1M".
-- Jared Vititoe <jjvititoe1@gmail.com> Sun, 11 Oct 2026 23:07:00 -0400
devdesk (0.9.4) bookworm; urgency=medium
* The inbox badge no longer only grows (#42). Opening the bell marks its
events seen, leaving just what is live (sessions waiting on you,
firing alerts). Viewing a session reads the events it reported while
you were away.
-- Jared Vititoe <jjvititoe1@gmail.com> Sun, 11 Oct 2026 22:46:00 -0400
devdesk (0.9.3) bookworm; urgency=medium
* Claude's footer shows 5-hour plan use instead of a dollar figure on a
Pro/Max subscription (#40). Claude Code's cost is an API-price
estimate, not a bill; it is still shown for API-key billing. The
Sessions tooltip follows the same rule.
* The bottom row is larger and easier to read (#41): 40px tall, larger
hints, and 5h/week usage meters in bold monospace with wider bars.
Narrow windows drop the least-used hints instead of clipping the
meters.
* Tests clear inherited DevDesk variables, so they pass when run from
inside a DevDesk session.
-- Jared Vititoe <jjvititoe1@gmail.com> Sun, 11 Oct 2026 22:08:00 -0400
devdesk (0.9.2) bookworm; urgency=medium
* Ops Alerts → Ask Claude starts an investigation (#39):
+8 -1
View File
@@ -46,9 +46,16 @@ DEVDESK_PUBLIC_URL=https://dev.lotusguild.org
# "Claude needs you" in Matrix when a session waits and no tab or SSH client is
# attached: put a hookshot webhook URL in this file (mode 600). No file = off.
# Only real requests (a permission prompt, a question), never Claude's idle
# reminder, and never while a DevDesk tab is in view.
# DEVDESK_MATRIX_DELAY: seconds the wait must last before posting.
# DEVDESK_MATRIX_QUIET: at most one message per session in this many seconds.
DEVDESK_MATRIX_WEBHOOK_FILE=/etc/devdesk/matrix-webhook
DEVDESK_MATRIX_DELAY=30
DEVDESK_MATRIX_DELAY=60
DEVDESK_MATRIX_QUIET=600
# DEVDESK_USAGE_WARN: post to the same room once per 5-hour window when plan usage
# passes this percent while no browser tab or SSH client is attached (devdesk#45).
DEVDESK_USAGE_WARN=90
# 0 turns off the guard that makes Claude ask before destructive commands
# (pct/zfs/ceph destroy, rm -r /…, force pushes, reboots). Extra rules:
+21
View File
@@ -111,3 +111,24 @@ cmd_reply() {
rm -f "$tmp"
trap - HUP INT TERM
}
# --last-reply SESSION (devdesk#47): Claude's whole answer to your last message,
# every text part of it (between tool calls), as the Markdown Claude wrote.
cmd_last_reply() {
[ $# -eq 1 ] || dd_die "usage: devdesk attach --last-reply SESSION"
local session=$1 transcript
dd_valid_name "$session" || dd_die "invalid session name: $session"
transcript=$(dd_transcript "$session") || transcript=""
{ [ -n "$transcript" ] && [ -r "$transcript" ]; } || dd_die "no conversation recorded for '$session' yet"
tail -n 3000 -- "$transcript" | jq -cMs --arg s "$session" '
def text: if type == "string" then . else ([.[]? | select(.type == "text") | .text] | join("\n")) end;
[.[] | select(.isSidechain != true and .isMeta != true)] as $all
# Your last message: a user entry with text of its own, not a tool result.
| ([range(0; $all | length) | select($all[.] as $e | $e.type == "user"
and ($e.message.content | type == "string" or any(.[]?; .type == "text"))
and ($e.message.content | text | startswith("<") | not))] | last // -1) as $u
| [$all[($u + 1):][] | select(.type == "assistant") | .message.content | text | select(length > 0)] as $parts
| if ($parts | length) == 0 then {session: $s, text: null}
else {session: $s, text: ($parts | join("\n\n")), at: ([$all[] | select(.type == "assistant")] | last | .timestamp)} end' 2>/dev/null ||
dd_die "could not read the conversation of '$session'"
}
+3 -1
View File
@@ -58,7 +58,9 @@ dd_load_env
: "${DEVDESK_UPLOAD_MAX:=26214400}"
: "${DEVDESK_PUBLIC_URL:=}"
: "${DEVDESK_MATRIX_WEBHOOK_FILE:=/etc/devdesk/matrix-webhook}"
: "${DEVDESK_MATRIX_DELAY:=30}"
: "${DEVDESK_MATRIX_DELAY:=60}"
: "${DEVDESK_MATRIX_QUIET:=600}"
: "${DEVDESK_USAGE_WARN:=90}"
: "${DEVDESK_PRESENCE_DIR:=/run/devdesk/presence}"
DEVDESK_REGISTRY="$DEVDESK_STATE_DIR/registry.json"
+56 -19
View File
@@ -1,15 +1,48 @@
#!/bin/bash
# devdesk-notify-matrix SESSION SINCE
# devdesk-notify-matrix --usage PCT RESETS_AT
#
# Started (detached) by devdesk-state-hook when a session starts waiting on you.
# After DEVDESK_MATRIX_DELAY seconds, if that same wait is still on and nobody is
# attached to the session (no browser tab, no SSH client), post one "Claude needs
# you" message to the hookshot webhook in DEVDESK_MATRIX_WEBHOOK_FILE (devdesk#28).
# After DEVDESK_MATRIX_DELAY seconds, if that same wait is still on, no DevDesk
# tab is in view, and this session hasn't posted in DEVDESK_MATRIX_QUIET seconds,
# post what Claude is asking to the hookshot webhook in DEVDESK_MATRIX_WEBHOOK_FILE
# (devdesk#28, #50).
set -euo pipefail
# shellcheck source=libexec/devdesk-lib.sh
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
# Post BODY (JSON with a .text) to the hookshot webhook. The URL is a secret
# (anyone holding it can post to the room): it goes to curl on stdin, never on
# the command line.
post() {
local webhook
# shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the URL
webhook=$(tr -d '[:space:]"\\' <"$DEVDESK_MATRIX_WEBHOOK_FILE")
printf 'url = "%s"\n' "$webhook" |
curl -fsS --max-time 10 --retry 2 -K - -H 'Content-Type: application/json' --data-binary "$1" >/dev/null
}
# devdesk-notify-matrix --usage PCT RESETS_AT (devdesk#45): started by the status
# line when the 5-hour plan window passes DEVDESK_USAGE_WARN percent. Once per
# window, and only when no client is attached to any session (you're away; a
# browser tab shows its own warning).
if [ "${1:-}" = --usage ]; then
pct=${2:-} resets=${3:-}
[[ $pct =~ ^[0-9]+$ && $resets =~ ^[0-9]+$ ]] || exit 0
marker="$DEVDESK_STATE_DIR/usage-warned"
exec 9>>"$marker.lock"
flock -n 9 || exit 0
[ "$(cat "$marker" 2>/dev/null)" != "$resets" ] || exit 0
printf '%s\n' "$resets" >"$marker"
[ -r "$DEVDESK_MATRIX_WEBHOOK_FILE" ] || exit 0
[ -z "$(dd_tmux list-clients -F x 2>/dev/null)" ] || exit 0
at=$(date -d "@$resets" '+%H:%M %Z')
post "$(jq -cMn --arg p "$pct" --arg at "$at" --arg h "$(hostname)" \
'{kind: "usage", host: $h, text: "Claude plan usage is at \($p)% of the 5-hour window; it resets at \($at)."}')"
exit 0
fi
session=${1:-}
since=${2:-}
dd_valid_name "$session" || exit 0
@@ -21,25 +54,29 @@ sleep "$DEVDESK_MATRIX_DELAY"
state_file="$DEVDESK_STATUS_DIR/$session.json"
# Still the same wait? (Answering it moves the state on; a new wait has its own notifier.)
[ "$(jq -rM '"\(.state) \(.since)"' "$state_file" 2>/dev/null)" = "waiting $since" ] || exit 0
# Anyone looking at it already?
attached=$(dd_tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev/null |
awk -v s="$session" '$1 == s { print $2 }')
[ "${attached:-0}" = 0 ] || exit 0
# You're at DevDesk if any tab has shown a session in the last ~2 minutes
# (presence markers, devdesk#26): the browser tells you itself (devdesk#50).
[ -z "$(find "$DEVDESK_PRESENCE_DIR" -type f 2>/dev/null | head -n 1)" ] || exit 0
# The idle reminder isn't a request (the state hook drops it; old states may have it).
message=$(jq -rM '.message // ""' "$state_file" 2>/dev/null)
request=$(jq -cM '.request // null' "$state_file" 2>/dev/null) || request=null
if [ "$request" = null ] && { [ -z "$message" ] || [[ $message == *"waiting for your input"* ]]; }; then exit 0; fi
# At most one message per session per DEVDESK_MATRIX_QUIET seconds.
sent="$DEVDESK_STATE_DIR/matrix-sent/$session"
mkdir -p "${sent%/*}"
if [ -n "$(find "$sent" -newermt "-$DEVDESK_MATRIX_QUIET seconds" 2>/dev/null)" ]; then exit 0; fi
touch "$sent"
message=$(jq -rM '.message // "Claude is waiting for your input"' "$state_file" 2>/dev/null)
detail=$(jq -rM '.request.detail // empty' "$state_file" 2>/dev/null)
[ -z "$detail" ] || message="$message: ${detail:0:300}"
url=""
# 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))}')
# What exactly it wants: "alpha: Claude wants to run Bash (Remove x)" and the command.
body=$(jq -cMn --arg s "$session" --arg m "$message" --argjson r "$request" --arg u "$url" --arg h "$(hostname)" '
(if $r then "\($s): Claude wants to run \($r.tool)\(if $r.description then " (\($r.description))" else "" end)"
else "\($s): \($m)" end) as $head
| ($r.detail // null | if . then .[0:300] else null end) as $d
| {session: $s, message: $m, url: (if $u == "" then null else $u end), host: $h,
text: ($head + (if $d then "\n" + $d else "" end) + (if $u == "" then "" else "\nAnswer: " + $u end))}')
# The webhook URL is a secret (anyone holding it can post to the room): pass it
# to curl on stdin, never on the command line.
# shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the URL
webhook=$(tr -d '[:space:]"\\' <"$DEVDESK_MATRIX_WEBHOOK_FILE")
printf 'url = "%s"\n' "$webhook" |
curl -fsS --max-time 10 --retry 2 -K - -H 'Content-Type: application/json' --data-binary "$body" >/dev/null
post "$body"
+11 -2
View File
@@ -21,8 +21,17 @@ session=$(tmux display-message -p -t "$TMUX_PANE" '#S' 2>/dev/null)
# Unit separator, not tab: read collapses runs of whitespace delimiters, so an
# empty field would shift the ones after it.
IFS=$'\x1f' read -r event message tool file < <(jq -rM '[.hook_event_name // "", (.message // "" | gsub("[\u001f\n]"; " ")),
.tool_name // "", (.tool_input.file_path // .tool_input.notebook_path // "" | gsub("[\u001f\n]"; " "))] | join("\u001f")' <<<"$input" 2>/dev/null)
IFS=$'\x1f' read -r event message tool file ntype < <(jq -rM '[.hook_event_name // "", (.message // "" | gsub("[\u001f\n]"; " ")),
.tool_name // "", (.tool_input.file_path // .tool_input.notebook_path // "" | gsub("[\u001f\n]"; " ")),
.notification_type // ""] | join("\u001f")' <<<"$input" 2>/dev/null)
# Claude's idle reminder ("Claude is waiting for your input", about a minute
# after a turn ends) is not a request: the turn is simply done. It must not
# turn the session red or reach Matrix (devdesk#50).
if [ "$event" = Notification ] &&
{ [ "$ntype" = idle_prompt ] || { [ -z "$ntype" ] && [[ $message == *"waiting for your input"* ]]; }; }; then
exit 0
fi
# Files Claude edits, for the file viewer's "Claude" badge (devdesk#19).
case $event:$tool in
+23 -4
View File
@@ -43,13 +43,32 @@ if [[ $session =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]]; then
fi
fi
# Footer line: session · model · context % · cost. ANSI colour numbers, not RGB,
# so the browser's dark or light palette applies (devdesk#31).
# Plan usage past DEVDESK_USAGE_WARN: ping Matrix if you're away (devdesk#45).
# The marker holds the window already warned about, so this stays one read.
if [[ $session != "" ]] && read -r pct resets < <(jq -rM '.rate_limits.five_hour
| select(.used_percentage != null and .resets_at != null)
| "\(.used_percentage | floor) \(.resets_at)"' <<<"$input" 2>/dev/null) &&
[ "$pct" -ge "${DEVDESK_USAGE_WARN:-90}" ] &&
[ "$(cat "${DEVDESK_STATE_DIR:-/var/lib/devdesk}/usage-warned" 2>/dev/null)" != "$resets" ]; then
setsid -f "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-notify-matrix" --usage "$pct" "$resets" \
</dev/null >/dev/null 2>&1 || true
fi
# Footer line: session · model · context % and tokens · plan use. ANSI colour numbers, not
# RGB, so the browser's dark or light palette applies (devdesk#31). Claude sends
# rate_limits only to Pro/Max subscribers; there .cost is an API-price estimate,
# not a bill, so show the 5-hour plan use instead and keep $ for API-key billing.
jq -rM --arg s "$session" '
def c($sgr; $t): "\u001b[\($sgr)m\($t)\u001b[0m";
def pct: if . == null then "—" else "\(. | floor)%" end;
def tok: if . >= 1000000 then "\(. / 100000 | round / 10)M" elif . >= 1000 then "\(. / 1000 | round)k" else "\(.)" end;
def ctxtok: .context_window | (.current_usage | if . then (.input_tokens // 0) + (.cache_creation_input_tokens // 0)
+ (.cache_read_input_tokens // 0) else null end) as $u
| if $u == null then "" else " \($u | tok)\(if .context_window_size then "/\(.context_window_size | tok)" else "" end)" end;
[ (if $s != "" then c("33"; "◈ \($s)") else empty end),
c("36"; .model.display_name // "Claude"),
c("37"; "ctx \(.context_window.used_percentage | pct)"),
c("37"; "$\((.cost.total_cost_usd // 0) * 100 | round / 100)")
c("37"; "ctx \(.context_window.used_percentage | pct)\(ctxtok)"),
(if .rate_limits.five_hour.used_percentage != null
then c("37"; "5h \(.rate_limits.five_hour.used_percentage | pct)")
else c("37"; "$\((.cost.total_cost_usd // 0) * 100 | round / 100)") end)
] | join(c("90"; " · "))' <<<"$input" 2>/dev/null || true
+137 -11
View File
@@ -5,6 +5,7 @@ REPO=$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)
setup() {
TMP=$(mktemp -d /tmp/devdesk-test.XXXXXX) # short path: unix socket limit
unset DEVDESK_REMOTE_CONTROL CLAUDE_ARGS DEVDESK_SESSION TMUX TMUX_PANE # when run from inside DevDesk
export DEVDESK_ENV=/dev/null
export DEVDESK_PROJECTS_ROOT="$TMP/projects"
export DEVDESK_STATE_DIR="$TMP/state"
@@ -13,6 +14,7 @@ setup() {
export DEVDESK_SHARE="$REPO/share"
export DEVDESK_NO_SYSTEMD=1
export DEVDESK_MATRIX_WEBHOOK_FILE="$TMP/no-webhook" # never the real room
export DEVDESK_PRESENCE_DIR="$TMP/presence" # never the live tabs
export FAKE_LOG="$TMP/claude-args"
export CLAUDE_BIN="$TMP/fake-claude"
export CLAUDE_CONFIG_DIR="$TMP/claude" # never the real conversation history
@@ -29,6 +31,7 @@ EOF
}
teardown() {
[ -z "${RECEIVER:-}" ] || kill "$RECEIVER" 2>/dev/null || true
tmux -S "$DEVDESK_SOCKET" kill-server 2>/dev/null || true
rm -rf "$TMP"
}
@@ -194,12 +197,23 @@ claude_args() {
"cost":{"total_cost_usd":1.5,"total_lines_added":10,"total_lines_removed":2}}'
run bash -c "echo '$status_json' | TMUX='$DEVDESK_SOCKET,1,0' TMUX_PANE='$pane' '$REPO/libexec/devdesk-statusline'"
[ "$status" -eq 0 ]
[[ $output == *"alpha"* && $output == *"ctx 34%"* ]]
[[ $output == *"alpha"* && $output == *"ctx 34% 346k/1M"* ]]
run devdesk attach --list
[ "$(jq -r '.sessions[] | select(.name=="alpha") | .context.used_tokens' <<<"$output")" = 346000 ]
[ "$(jq -r '.sessions[] | select(.name=="alpha") | .context.model' <<<"$output")" = Opus ]
}
@test "statusline footer shows plan use on a subscription and dollars only on API billing" {
sub='{"model":{"display_name":"Opus"},"context_window":{"used_percentage":10},
"cost":{"total_cost_usd":1186.99},"rate_limits":{"five_hour":{"used_percentage":8.4}}}'
run bash -c "echo '$sub' | '$REPO/libexec/devdesk-statusline'"
[[ $output == *"5h 8%"* ]]
[[ $output != *'$'* ]]
api='{"model":{"display_name":"Opus"},"context_window":{"used_percentage":10},"cost":{"total_cost_usd":1.5}}'
run bash -c "echo '$api' | '$REPO/libexec/devdesk-statusline'"
[[ $output == *'$1.5'* ]]
}
@test "hooks ignore Claude running in some other tmux server" {
start_server
other="$TMP/other.sock"
@@ -298,21 +312,28 @@ PY
posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; }
@test "a wait nobody is attached to is posted to Matrix once (#28)" {
@test "a real wait is posted to Matrix once, saying what Claude asks (#28, #50)" {
start_server
start_receiver
export DEVDESK_MATRIX_DELAY=1 DEVDESK_PUBLIC_URL=https://dev.example.org
devdesk new alpha
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
hook() { TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; }
st="$DEVDESK_STATE_DIR/state/alpha.json"
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Bash"}' | hook
# a second notification in the same wait: no second message
# Claude's idle reminder after a turn is not a request: no red, no post.
echo '{"hook_event_name":"Stop"}' | hook
echo '{"hook_event_name":"Notification","message":"Claude is waiting for your input","notification_type":"idle_prompt"}' | hook
echo '{"hook_event_name":"Notification","message":"Claude is waiting for your input"}' | hook
[ "$(jq -r .state "$st")" = idle ]
echo '{"hook_event_name":"PermissionRequest","tool_name":"Bash","tool_input":{"command":"systemctl restart nginx","description":"Restart nginx"}}' | hook
# a second notification in the same wait: no second message
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Bash","notification_type":"permission_prompt"}' | hook
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&existing=1\ttrue')" ]
run jq -r '.text' "$TMP/posts"
[ "$output" = "$(printf 'alpha: Claude wants to run Bash (Restart nginx)\nsystemctl restart nginx\nAnswer: https://dev.example.org/?arg=alpha&existing=1')" ]
# answered within the delay: nothing is posted
echo '{"hook_event_name":"PostToolUse"}' | hook
@@ -321,12 +342,18 @@ posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; }
sleep 2
[ "$(posts)" -eq 1 ]
# no webhook file: feature off
rm "$TMP/webhook"
echo '{"hook_event_name":"Notification","message":"again"}' | hook
sleep 2
# another real wait soon after: quiet period, still one message
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Edit"}' | hook
sleep 2.5
[ "$(posts)" -eq 1 ]
echo '{"hook_event_name":"PostToolUse"}' | hook
# past the quiet period, but a DevDesk tab is in view: nothing
rm -rf "$DEVDESK_STATE_DIR/matrix-sent"
mkdir -p "$DEVDESK_PRESENCE_DIR" && touch "$DEVDESK_PRESENCE_DIR/1"
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Edit"}' | hook
sleep 2.5
[ "$(posts)" -eq 1 ]
kill "$RECEIVER"
}
@test "under ttyd, one-shot replies wait for the browser to hang up (#35)" {
@@ -1056,3 +1083,102 @@ fake_grafana() {
run devdesk attach --investigate 'bad name' u-ok
[[ $output == *"invalid session name"* ]]
}
@test "last-reply returns every text part of the answer to your last message (#47)" {
start_server
devdesk new alpha
export CLAUDE_CONFIG_DIR="$TMP/claude"
id=11111111-2222-3333-4444-555555555555
mkdir -p "$CLAUDE_CONFIG_DIR/projects/-x"
cat >"$CLAUDE_CONFIG_DIR/projects/-x/$id.jsonl" <<'JSONL'
{"type":"user","message":{"role":"user","content":"First question"},"timestamp":"2026-10-10T09:00:00Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Old answer"}]},"timestamp":"2026-10-10T09:00:01Z"}
{"type":"user","message":{"role":"user","content":"Please upgrade PBS"},"timestamp":"2026-10-10T10:00:00Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Checking the version."}]},"timestamp":"2026-10-10T10:00:04Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{}}]},"timestamp":"2026-10-10T10:00:05Z"}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"ok"}]},"timestamp":"2026-10-10T10:00:06Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"## Done\n\nPBS is on **4.1**."}]},"timestamp":"2026-10-10T10:00:09Z"}
JSONL
run devdesk attach --last-reply alpha
[ "$status" -ne 0 ] # no conversation recorded yet
jq --arg id "$id" '.sessions.alpha.claude_session_id = $id' "$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json"
run devdesk attach --last-reply alpha
[ "$status" -eq 0 ]
[ "$(jq -r .text <<<"$output")" = "$(printf 'Checking the version.\n\n## Done\n\nPBS is on **4.1**.')" ]
[ "$(jq -r .at <<<"$output")" = 2026-10-10T10:00:09Z ]
}
@test "worktree sessions get their own checkout; ending removes it only when clean (#44)" {
start_server
repo="$DEVDESK_PROJECTS_ROOT/alpha"
git -C "$repo" init -q -b main
git -C "$repo" -c user.name=t -c user.email=t@t commit -q --allow-empty -m init
mkdir -p "$DEVDESK_PROJECTS_ROOT/plain"
run devdesk new solo --worktree
[ "$status" -ne 0 ]
run devdesk new p1 plain --worktree
[ "$status" -ne 0 ]
[[ $output == *"not a git repository"* ]]
devdesk new wt1 alpha --worktree
wt="$DEVDESK_PROJECTS_ROOT/.worktrees/alpha--wt1"
[ "$(git -C "$wt" symbolic-ref --short HEAD)" = devdesk/wt1 ]
run tmux -S "$DEVDESK_SOCKET" display -p -t =wt1:claude '#{pane_current_path}'
[ "$output" = "$wt" ]
run devdesk attach --list
[ "$(jq -r '.sessions[] | select(.name=="wt1") | .worktree.branch' <<<"$output")" = devdesk/wt1 ]
[ "$(jq -r '.projects[] | select(.name=="alpha") | .git' <<<"$output")" = true ]
[ "$(jq -r '.projects[] | select(.name=="plain") | .git' <<<"$output")" = false ]
[ "$(jq -r '[.projects[].name] | index(".worktrees")' <<<"$output")" = null ]
# clean and merged: worktree and branch go
devdesk end wt1
[ ! -e "$wt" ]
run git -C "$repo" show-ref --verify refs/heads/devdesk/wt1
[ "$status" -ne 0 ]
# uncommitted work: everything stays
devdesk new wt2 alpha --worktree
wt="$DEVDESK_PROJECTS_ROOT/.worktrees/alpha--wt2"
echo work >"$wt/file"
devdesk end wt2
[ -f "$wt/file" ]
git -C "$repo" show-ref -q --verify refs/heads/devdesk/wt2
}
@test "repo lists the first changed files for the panel (#46)" {
export DEVDESK_GITEA_URL=http://127.0.0.1:9
repo="$DEVDESK_PROJECTS_ROOT/alpha"
git -C "$repo" init -q -b main
printf 'a\n' >"$repo/tracked"
git -C "$repo" add tracked
git -C "$repo" -c user.name=t -c user.email=t@t commit -q -m init
printf 'a\nb\n' >"$repo/tracked"
touch "$repo/new-file"
run devdesk attach --repo alpha
[ "$status" -eq 0 ]
[ "$(jq -c '[.changes[] | [.path, .x, .y, .add]]' <<<"$output")" = '[["tracked"," ","M",1],["new-file","?","?",null]]' ]
}
@test "plan usage past the threshold pings Matrix once per window, only when away (#45)" {
start_server
start_receiver
devdesk new alpha
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
sl() { echo "{\"model\":{\"display_name\":\"Opus\"},\"context_window\":{},\"rate_limits\":{\"five_hour\":{\"used_percentage\":$1,\"resets_at\":$2}}}" |
TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-statusline" >/dev/null; }
sl 50 2000000000
sl 91 2000000000
sl 93 2000000000
for _ in $(seq 1 30); do [ "$(posts)" -ge 1 ] && break; sleep 0.1; done
sleep 0.5
[ "$(posts)" -eq 1 ]
[[ "$(jq -r .text "$TMP/posts")" == *"91% of the 5-hour window"* ]]
# next window, but a client is attached: no post, and that window is done
tmux -S "$DEVDESK_SOCKET" new-session -d -s watcher 'sleep 30'
script -qc "tmux -S '$DEVDESK_SOCKET' attach -t =alpha" /dev/null </dev/null >/dev/null 2>&1 &
for _ in $(seq 1 30); do [ -n "$(tmux -S "$DEVDESK_SOCKET" list-clients -F x)" ] && break; sleep 0.1; done
sl 95 2000018000
sleep 1
[ "$(posts)" -eq 1 ]
[ "$(cat "$DEVDESK_STATE_DIR/usage-warned")" = 2000018000 ]
}
+3 -1
View File
@@ -28,7 +28,7 @@
</div>
<div class="dd-session">
<button type="button" class="dd-session-chip" id="session-chip" data-state="" aria-haspopup="menu" aria-expanded="false" title="Switch session">
<span class="dd-state-dot" aria-hidden="true"></span><span class="dd-session-label" id="session-name">—</span><span class="dd-caret" aria-hidden="true">▾</span>
<span class="dd-state-dot" aria-hidden="true"></span><span class="dd-session-label" id="session-name">—</span><span class="dd-turn-timer" id="turn-timer" hidden></span><span class="dd-caret" aria-hidden="true">▾</span>
</button>
<span class="dd-pane-title" id="pane-title"></span>
<div class="dd-menu dd-switcher" id="switcher" role="menu" hidden></div>
@@ -131,6 +131,7 @@
aria-label="New session name" spellcheck="false">
<select class="lt-select" id="project-select" aria-label="Start the new session in a project folder (optional)"></select>
<button type="submit" class="lt-btn lt-btn-primary lt-btn-sm">Open</button>
<label class="dd-check" id="new-session-worktree-label" hidden title="A separate checkout on branch devdesk/NAME, so this Claude never edits the same files as another session"><input type="checkbox" id="new-session-worktree"> own git worktree</label>
</form>
<ul class="dd-session-list" id="sessions-list"></ul>
<span class="dd-muted dd-status" id="sessions-status" role="status"></span>
@@ -155,6 +156,7 @@
<span class="lt-kv-key">CI · HEAD</span><span class="lt-kv-val"><span class="lt-status" data-field="ci">—</span></span>
<span class="lt-kv-key">Open PRs</span><span class="lt-kv-val" data-field="prs">—</span>
</div>
<ul class="dd-repo-changes" hidden></ul>
<p class="dd-muted dd-repo-note"></p>
<button type="button" class="lt-btn lt-btn-sm dd-ask dd-fix-ci" hidden>Ask Claude to fix CI</button>
</div>
+8
View File
@@ -224,6 +224,14 @@ export async function peekSession(session) {
return parseListOutput(await runCommand(['--peek', session], 15000));
}
// Claude's whole last answer, as Markdown (devdesk#47): {session, text, at}.
export async function lastReply(session) {
const output = stripAnsi(await runCommand(['--last-reply', session], 15000)).trim();
if (output.includes('"session"')) return parseListOutput(output);
const message = output.split(/\r?\n/).filter(Boolean).pop() || 'no response';
throw new Error(message.replace(/^devdesk:\s*/, ''));
}
export async function replyTo(session, text) {
const bytes = new TextEncoder().encode(text);
const result = await limited(() => streamIn(['--reply', session, String(bytes.length)], 'DEVDESK-REPLY-READY', new Blob([bytes])));
+26 -2
View File
@@ -69,7 +69,27 @@ export class Inbox {
this.button.setAttribute('aria-expanded', String(open));
if (!open) this.peeking = null;
this.panel.classList.toggle('is-peek', false);
if (open) this.render();
if (open) {
this.render();
this.markSeen();
}
}
// Opening the bell counts as seeing what's in it (devdesk#42): the badge clears
// but the entries keep their highlight until the panel is redrawn.
markSeen() {
if (!this.events.some((e) => !e.read)) return;
this.events = this.events.map((e) => ({ ...e, read: true }));
save(this.events);
this.renderBadge();
}
// Looking at a session reads what it reported (finished, waiting) while you were away.
markSessionRead(session) {
if (!session || !this.events.some((e) => !e.read && e.session === session && !e.url)) return;
this.events = this.events.map((e) => (e.session === session && !e.url ? { ...e, read: true } : e));
save(this.events);
this.render();
}
// Something happened. The same kind for the same session within a minute is
@@ -102,6 +122,11 @@ export class Inbox {
}
render() {
this.renderBadge();
if (this.isOpen) this.renderPanel();
}
renderBadge() {
const count = this.waiting.length + this.alerts.length + this.unread();
const urgent = this.waiting.length > 0 || this.alerts.some((a) => a.severity === 'critical');
this.badge.textContent = count ? String(count) : '';
@@ -114,7 +139,6 @@ export class Inbox {
this.unread() && `${this.unread()} new event(s)`,
].filter(Boolean).join(' · ')
: 'Inbox: nothing new';
if (this.isOpen) this.renderPanel();
}
// ------------------------------------------------------------ answer from here
+91 -10
View File
@@ -2,10 +2,11 @@
import { THEMES, createTerminal, loadFonts } from './term.js';
import { getTheme, initTheme, onThemeChange, toggleTheme } from './theme.js';
import {
TtydConnection, fetchAlerts, fetchRepo, fetchToken, investigateAlert, sessionAction, uploadFile, AuthExpiredError, UnauthorizedError,
TtydConnection, fetchAlerts, fetchRepo, fetchToken, investigateAlert, lastReply, replyTo, sessionAction, uploadFile,
AuthExpiredError, UnauthorizedError,
} from './connection.js';
import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.js';
import { SessionsPanel, recentNames, rememberSession, sessionStatus } from './sessions.js';
import { SessionsPanel, ctxLabel, elapsed, recentNames, rememberSession, sessionStatus } from './sessions.js';
import { renderAlerts } from './alerts.js';
import { renderRepo } from './repo.js';
import { bindAskClaude } from './widgets.js';
@@ -125,10 +126,17 @@ function renderSessionChip() {
const status = current ? sessionStatus(current) : null;
ui.sessionName.textContent = state.session || 'picker';
ui.sessionChip.dataset.state = status?.key ?? '';
// How long the current turn has been running (devdesk#48).
const turn = status?.key === 'working' && current.status?.since ? elapsed(current.status.since) : '';
const timer = $('turn-timer');
timer.hidden = !turn;
timer.textContent = turn;
notify.setStatusPrefix(turn ? `⏳ ${turn} · ` : status?.key === 'waiting' ? '❗ ' : '');
ui.sessionChip.title = [
state.session ? `Session ${state.session}${status ? ` · ${status.label}` : ''}` : 'Session picker',
current?.status?.message,
current?.context?.used_pct != null ? `context ${Math.floor(current.context.used_pct)}%` : '',
turn ? `working on this turn for ${turn}` : '',
current?.context?.used_pct != null ? ctxLabel(current.context).replace(/^ctx/, 'context') : '',
'Click to switch session',
].filter(Boolean).join('\n');
}
@@ -181,6 +189,7 @@ function renderPanelsBadge() {
}
function onSessionData({ fetched = false } = {}) {
if (document.visibilityState === 'visible') inbox?.markSessionRead(state.session);
renderUsage();
renderSessionChip();
renderAttention(fetched);
@@ -284,6 +293,14 @@ function wireUploads() {
// Repo / CI panel for the current session; notifies when HEAD's CI finishes.
let lastCi = { key: '', state: '' };
// Repo panel → diffs in the file pane (devdesk#46).
const repoHooks = {
openChanges: () => files.open('changes'),
openDiff: (path, diffable) => {
files.open('changes');
files.openFile(path, { mode: diffable ? 'diff' : undefined, pin: true });
},
};
let repoBusy = false;
async function refreshRepo() {
const session = state.session;
@@ -297,7 +314,7 @@ async function refreshRepo() {
try {
const repo = await fetchRepo(session);
if (session !== state.session) return;
renderRepo($('widget-repo'), repo);
renderRepo($('widget-repo'), repo, repoHooks);
lastRepo = repo;
const key = `${session}@${repo.sha}`;
const ci = repo.ci?.total ? repo.ci.state : '';
@@ -546,7 +563,7 @@ function renderSwitcher() {
};
const items = sessions.map((s) => {
const status = sessionStatus(s);
const ctx = s.context?.used_pct != null ? ` · ctx ${Math.floor(s.context.used_pct)}%` : '';
const ctx = s.context?.used_pct != null ? ` · ${ctxLabel(s.context)}` : '';
return el('button', {
type: 'button', role: 'menuitem',
class: `dd-menu-item dd-switch-item is-${status.key}${s.name === state.session ? ' is-current' : ''}`,
@@ -645,15 +662,28 @@ function renderUsage() {
return;
}
const at = (epoch) => (epoch ? new Date(epoch * 1000).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' }) : '?');
const meter = (label, w) => {
const clock = (epoch) => new Date(epoch * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
const day = (epoch) => new Date(epoch * 1000).toLocaleDateString(undefined, { weekday: 'short' });
// devdesk#45: when it resets, and when it fills at the pace of the window so
// far (straight line from the window's start), if that is before the reset.
const meter = (label, w, span, short) => {
if (!w || w.pct === null || w.pct === undefined) return null;
const pct = Math.round(w.pct);
return el('span', { class: `dd-meter${pct >= 95 ? ' is-full' : pct >= 80 ? ' is-high' : ''}`, title: `${label}: ${pct}% used, resets ${at(w.resets_at)}` },
const now = Date.now() / 1000;
const start = w.resets_at ? w.resets_at - span : null;
const used = start ? now - start : 0;
const full = start && w.pct >= 5 && w.pct < 100 && used >= 600 ? now + ((100 - w.pct) * used) / w.pct : null;
const early = full && full < w.resets_at ? full : null;
const title = [`${label}: ${pct}% used, resets ${at(w.resets_at)}`,
early ? `At this pace it is used up around ${at(early)}, before it resets` : start && pct < 100 ? 'At this pace it lasts until it resets' : ''].filter(Boolean).join('\n');
return el('span', { class: `dd-meter${pct >= 95 ? ' is-full' : pct >= 80 || early ? ' is-high' : ''}`, title },
el('span', { class: 'dd-meter-label' }, label),
el('span', { class: 'dd-meter-bar' }, el('span', { class: 'dd-meter-fill', style: `width:${Math.min(100, pct)}%` })),
`${pct}%`);
`${pct}%`,
early ? el('span', { class: 'dd-meter-pace' }, `⚠ full ~${short(early)}`)
: w.resets_at ? el('span', { class: 'dd-meter-reset' }, `↻ ${short(w.resets_at)}`) : null);
};
const parts = () => [meter('5h', latest.five_hour), meter('week', latest.seven_day)].filter(Boolean);
const parts = () => [meter('5h', latest.five_hour, 5 * 3600, clock), meter('week', latest.seven_day, 7 * 86400, day)].filter(Boolean);
footer.replaceChildren(...parts());
$('usage-menu').replaceChildren(...parts());
footer.hidden = false;
@@ -682,6 +712,44 @@ function presenceTick() {
const sendKeys = (keys) => state.conn?.sendInput(keys);
// Claude's whole last answer as Markdown (devdesk#47). The clipboard write starts
// inside the click (Safari needs that) and gets its text when the server answers.
async function copyReply(session) {
const text = lastReply(session).then((r) => {
if (!r.text) throw new Error(`${session}: Claude hasn't answered anything yet`);
return r.text;
});
try {
if (window.ClipboardItem && navigator.clipboard?.write) {
await navigator.clipboard.write([new ClipboardItem({ 'text/plain': text.then((t) => new Blob([t], { type: 'text/plain' })) })]);
} else {
await navigator.clipboard.writeText(await text);
}
const t = await text;
toast.success(`Copied ${session}'s last reply (${t.length.toLocaleString()} characters)`);
} catch (error) {
const reason = await text.then(() => null, (e) => e);
toast.error(reason ? reason.message : `Copy failed: ${error.message}`);
}
}
// /compact in a session whose context is filling up (devdesk#49). Only when Claude
// is idle: mid-turn or at a permission prompt the keys would land in the wrong place.
async function compact(session) {
const s = sessionsPanel?.data?.sessions?.find((x) => x.name === session);
const key = s ? sessionStatus(s).key : 'idle';
if (key === 'working') return toast.warning(`${session} is working: compact once the turn ends`);
if (key === 'waiting') return toast.warning(`${session} is waiting on you: answer it first`);
try {
await replyTo(session, '/compact');
toast.success(`Compacting ${session}'s context`);
sessionsPanel.reload();
} catch (error) {
toast.error(`${session}: ${error.message}`);
}
return undefined;
}
async function copyLink() {
const url = new URL(window.location.href);
url.search = state.session ? `?arg=${encodeURIComponent(state.session)}` : '';
@@ -722,6 +790,8 @@ function buildCommands() {
run: () => sessionsPanel.end(current ?? { name: state.session, running: true }),
});
add({ id: 'copy-link', group: 'Sessions', icon: '⧉', label: 'Copy link to this session', keywords: 'url share', run: copyLink });
add({ id: 'copy-reply', group: 'Sessions', icon: '⧉', label: 'Copy Claude\'s last reply (Markdown)', keywords: 'clipboard answer response markdown', run: () => copyReply(state.session) });
if (current?.running) add({ id: 'compact', group: 'Sessions', icon: '⇲', label: `Compact the context (/compact)${current.context?.used_pct != null ? ` · ${Math.floor(current.context.used_pct)}% full` : ''}`, keywords: 'context summarize shrink', run: () => compact(state.session) });
}
add({ id: 'picker', group: 'Sessions', icon: '≡', label: 'Open the session picker', keywords: 'fzf list', run: () => connect(null) });
add({ id: 'history', group: 'Sessions', icon: '⌕', label: 'Search past conversations…', keywords: 'history transcript resume fork find old', run: showHistory });
@@ -1074,12 +1144,20 @@ function wireChrome() {
window.addEventListener('online', retrySoon);
document.addEventListener('visibilitychange', () => {
if (!document.hidden) retrySoon();
if (!document.hidden) {
retrySoon();
inbox.markSessionRead(state.session);
}
});
// Session states feed the header chip and the notifications for other sessions,
// so poll even in a background tab (browsers throttle that to about once a
// minute) — unless another tab just shared a fresh list (devdesk#8, #15).
onShared('sessions', (data) => sessionsPanel.accept(data));
// The turn timer and the usage pace move between polls.
setInterval(() => {
renderSessionChip();
renderUsage();
}, 10 * 1000);
setInterval(() => {
if (!heardWithin('sessions', 16 * 1000)) sessionsPanel.refresh();
}, 15 * 1000);
@@ -1157,6 +1235,7 @@ async function boot() {
form: $('new-session-form'),
nameInput: $('new-session-name'),
select: $('project-select'),
worktree: $('new-session-worktree'),
refreshButton: $('sessions-refresh'),
newToggle: $('new-session-toggle'),
status: $('sessions-status'),
@@ -1166,6 +1245,8 @@ async function boot() {
connect(name);
},
onEnding: () => { state.endingCurrent = true; },
onCompact: (name) => compact(name),
onCopyReply: (name) => copyReply(name),
onEnded: (name) => {
if (name !== state.session) state.endingCurrent = false;
},
+9 -1
View File
@@ -6,13 +6,14 @@ import { FAVICON, FAVICON_BADGE } from './icon.js';
let unread = 0;
let baseTitle = document.title;
let statusPrefix = '';
function attentive() {
return !document.hidden && document.hasFocus();
}
function render() {
document.title = unread ? `(${unread}) ${baseTitle}` : baseTitle;
document.title = `${unread ? `(${unread}) ` : ''}${statusPrefix}${baseTitle}`;
const link = document.getElementById('favicon');
if (link) link.href = unread ? FAVICON_BADGE : FAVICON;
}
@@ -22,6 +23,13 @@ export function setBaseTitle(title) {
render();
}
// "⏳ 3m · " while Claude works on a turn in this tab's session (devdesk#48).
export function setStatusPrefix(prefix) {
if (prefix === statusPrefix) return;
statusPrefix = prefix;
render();
}
export function notificationsState() {
if (!('Notification' in window)) return 'unsupported';
return Notification.permission; // granted | denied | default
+46 -2
View File
@@ -18,13 +18,16 @@ function link(href, text) {
return a;
}
export function renderRepo(root, repo) {
// hooks: openDiff(path, diffable), openChanges() (devdesk#46)
export function renderRepo(root, repo, hooks = {}) {
const field = (name) => root.querySelector(`[data-field="${name}"]`);
const set = (name, value) => field(name).replaceChildren(value);
const grid = root.querySelector('.dd-repo-grid');
const note = root.querySelector('.dd-repo-note');
const fix = root.querySelector('.dd-fix-ci');
fix.hidden = true;
const changes = root.querySelector('.dd-repo-changes');
changes.hidden = true;
// No repository: one line instead of a grid of dashes (devdesk#17).
if (!repo || !repo.git) {
@@ -41,7 +44,48 @@ export function renderRepo(root, repo) {
? [repo.ahead ? `↑${repo.ahead}` : '', repo.behind ? `↓${repo.behind}` : ''].filter(Boolean).join(' ') || 'in sync'
: 'no upstream';
set('branch', `${repo.branch} @ ${repo.sha || '—'} · ${sync}`);
set('dirty', repo.dirty ? `${repo.dirty} changed` : 'clean');
if (repo.dirty) {
const all = document.createElement('button');
all.type = 'button';
all.className = 'dd-link-btn';
all.textContent = `${repo.dirty} changed`;
all.title = 'Review all changes';
all.addEventListener('click', () => hooks.openChanges?.());
set('dirty', all);
} else {
set('dirty', 'clean');
}
// The first changed files: click one for its diff against HEAD.
const files = (repo.changes ?? []).filter((f) => f.path !== null && f.path !== undefined);
if (files.length) {
changes.hidden = false;
changes.replaceChildren(...files.map((f) => {
const diffable = f.x !== '?' && f.y !== '?';
const code = f.x === '?' ? 'new' : (f.x !== ' ' ? f.x : f.y);
const li = document.createElement('li');
const b = document.createElement('button');
b.type = 'button';
b.className = 'dd-repo-change';
b.title = `${f.path}${diffable ? ' · diff against HEAD' : ' · new file'}${f.claude ? ' · edited by Claude' : ''}`;
const name = document.createElement('span');
name.className = 'dd-repo-change-name';
name.textContent = f.path;
const stat = document.createElement('span');
stat.className = 'dd-repo-change-stat';
stat.textContent = [f.add ? `+${f.add}` : '', f.del ? `−${f.del}` : '', code === 'new' ? 'new' : code.trim()]
.filter(Boolean).join(' ');
b.append(name, stat);
b.addEventListener('click', () => hooks.openDiff?.(f.path, diffable));
li.append(b);
return li;
}));
if (repo.dirty > files.length) {
const more = document.createElement('li');
more.className = 'dd-muted';
more.textContent = `+${repo.dirty - files.length} more`;
changes.append(more);
}
}
const ci = field('ci');
const [label, cls] = repo.ci?.total ? (CI[repo.ci.state] || [repo.ci.state, 'lt-status-pending'])
+53 -13
View File
@@ -46,6 +46,22 @@ function renameRecent(from, to) {
writeRecents(readRecents().map((r) => (r.name === from ? { ...r, name: to } : r)));
}
// How long a turn has been running: "45s", "3m", "1h 05m" (devdesk#48).
export function elapsed(sinceSeconds) {
const s = Math.max(0, Math.floor(Date.now() / 1000 - sinceSeconds));
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m`;
return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`;
}
// "ctx 34% · 346k/1M": context use as a share of the window and in tokens.
const tokens = (n) => (n >= 1e6 ? `${+(n / 1e6).toFixed(n % 1e6 ? 1 : 0)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n));
export function ctxLabel(c) {
if (c?.used_pct == null) return '';
const count = c.used_tokens != null ? ` · ${tokens(c.used_tokens)}${c.window ? `/${tokens(c.window)}` : ''}` : '';
return `ctx ${Math.floor(c.used_pct)}%${count}`;
}
export function ago(epochSeconds) {
if (!epochSeconds) return '';
const s = Math.max(0, Date.now() / 1000 - epochSeconds);
@@ -71,9 +87,11 @@ export function sessionStatus(s) {
}
export class SessionsPanel {
// hooks: onOpen(name), onEnding(name), onEnded(name), onEndAborted(), onRenamed(from, to), onData(data)
constructor({ list, form, nameInput, select, refreshButton, newToggle, status, hooks }) {
Object.assign(this, { list, form, nameInput, select, newToggle, status, hooks });
// hooks: onOpen(name), onEnding(name), onEnded(name), onEndAborted(), onRenamed(from, to), onData(data),
// onCompact(name), onCopyReply(name)
constructor({ list, form, nameInput, select, worktree, refreshButton, newToggle, status, hooks }) {
Object.assign(this, { list, form, nameInput, select, worktree, newToggle, status, hooks });
select.addEventListener('change', () => this.worktreeOption(select, worktree));
this.current = null;
this.data = null;
this.pending = null;
@@ -145,6 +163,14 @@ export class SessionsPanel {
}
}
// The worktree choice only makes sense for a project that is a git repository (devdesk#44).
worktreeOption(select, box) {
if (!box) return;
const git = Boolean(this.data?.projects?.find((p) => p.name === select.value && p.git && select.value));
box.closest('label').hidden = !git;
if (!git) box.checked = false;
}
async createFromForm() {
const project = this.select.value;
const name = slugify(this.nameInput.value) || project;
@@ -153,16 +179,17 @@ export class SessionsPanel {
this.nameInput.focus();
return;
}
if (!(await this.create(name, project))) return;
if (!(await this.create(name, project, this.worktree?.checked))) return;
this.nameInput.value = '';
this.select.value = '';
this.worktreeOption(this.select, this.worktree);
this.toggleForm(false);
}
// With a project the session starts in that folder; without one, opening it
// creates it in the default folder (or its own project folder, by name).
async create(name, project) {
if (project && !(await this.act('new', name, project))) return false;
async create(name, project, worktree = false) {
if (project && !(await this.act('new', name, project, ...(worktree ? ['--worktree'] : [])))) return false;
this.hooks.onOpen(name);
return true;
}
@@ -183,14 +210,19 @@ export class SessionsPanel {
const project = el('select', { class: 'lt-select' });
this.projectOptions(project);
const hint = el('p', { class: 'dd-dialog-hint', id: 'dd-new-hint' }, 'Letters, numbers, - and _. Any name works; pick a project to start in its folder.');
const worktree = el('input', { type: 'checkbox' });
const worktreeLabel = el('label', { class: 'dd-check', hidden: '' }, worktree,
el('span', {}, 'Own git worktree: a separate checkout on branch devdesk/NAME, so this Claude never edits the same files as another session'));
project.addEventListener('change', () => {
if (!name.value.trim()) name.placeholder = project.value || 'e.g. pbs-upgrade';
this.worktreeOption(project, worktree);
});
const result = await openDialog({
title: 'New session',
body: el('div', { class: 'dd-dialog-fields' },
el('label', { class: 'dd-field' }, el('span', {}, 'Name'), name),
el('label', { class: 'dd-field' }, el('span', {}, 'Project folder'), project),
worktreeLabel,
hint),
actions: [{ label: 'Cancel' }, { label: 'Open', primary: true }],
submit: () => {
@@ -201,10 +233,10 @@ export class SessionsPanel {
name.focus();
return null;
}
return { name: value, project: project.value };
return { name: value, project: project.value, worktree: worktree.checked };
},
});
if (result) await this.create(result.name, result.project);
if (result) await this.create(result.name, result.project, result.worktree);
}
async rename(name) {
@@ -246,9 +278,10 @@ export class SessionsPanel {
async end(session) {
const ok = await confirmDialog({
title: session.running ? `End ${session.name}?` : `Forget ${session.name}?`,
text: session.running
text: (session.running
? 'Claude in this session stops and the session closes. The conversation stays in Claude\'s history (claude --resume).'
: 'This dormant session is removed from the list. Its conversation stays in Claude\'s history.',
: 'This dormant session is removed from the list. Its conversation stays in Claude\'s history.')
+ (session.worktree ? ` Its worktree is removed if nothing in it is uncommitted; branch ${session.worktree.branch} is deleted only if it is merged.` : ''),
confirmLabel: session.running ? 'End session' : 'Forget',
danger: true,
});
@@ -267,8 +300,9 @@ export class SessionsPanel {
renderSession(s) {
const isCurrent = s.name === this.current;
const status = sessionStatus(s);
const ctx = s.context?.used_pct != null ? `ctx ${Math.floor(s.context.used_pct)}%` : '';
const since = s.status?.since ? ago(s.status.since) : ago(s.activity);
const ctx = ctxLabel(s.context);
const since = status.key === 'working' && s.status?.since ? elapsed(s.status.since)
: s.status?.since ? ago(s.status.since) : ago(s.activity);
const model = isCurrent && s.context?.model ? s.context.model.replace(/^Claude\s+/i, '') : '';
const meta = s.running
? [`${status.label}${since && since !== 'now' ? ` ${since}` : ''}`, ctx, model, s.attached ? `${s.attached} open` : '']
@@ -278,7 +312,8 @@ export class SessionsPanel {
const title = [
s.dir,
status.key === 'waiting' && s.status?.message,
s.context?.model && `${s.context.model}${s.context.cost_usd != null ? ` · $${s.context.cost_usd.toFixed(2)}` : ''}`,
// rate_limits means a Pro/Max plan, where cost_usd is only an API-price estimate
s.context?.model && `${s.context.model}${s.context.rate_limits ? '' : s.context.cost_usd != null ? ` · $${s.context.cost_usd.toFixed(2)}` : ''}`,
].filter(Boolean).join('\n');
const iconButton = (label, glyph, onclick, extra = '') =>
el('button', { class: `dd-icon-btn ${extra}`, type: 'button', title: label, 'aria-label': `${label} ${s.name}`, onclick }, glyph);
@@ -286,6 +321,7 @@ 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,
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)
s.context?.pr?.number ? el('a', {
@@ -297,6 +333,10 @@ export class SessionsPanel {
pct != null ? el('span', { class: `dd-ctx${pct >= 90 ? ' is-full' : pct >= 70 ? ' is-high' : ''}`, 'aria-hidden': 'true' },
el('span', { class: 'dd-ctx-fill', style: `width:${Math.min(100, pct)}%` })) : null),
el('span', { class: 'dd-session-actions' },
// Context nearly full and Claude idle: /compact from here (devdesk#49).
s.running && pct >= 80 && status.key === 'idle'
? iconButton(`Compact (context ${Math.floor(pct)}% full)`, '⇲', () => this.hooks.onCompact?.(s.name), 'dd-compact') : null,
s.running || s.claude_session_id ? iconButton('Copy Claude\'s last reply', '⧉', () => this.hooks.onCopyReply?.(s.name)) : null,
iconButton('Rename', '✎', () => this.rename(s.name)),
el('a', {
class: 'dd-icon-btn', href: `?arg=${encodeURIComponent(s.name)}`, target: '_blank', rel: 'noopener',
+45 -4
View File
@@ -2,7 +2,7 @@
everything visual comes from the TDS tokens defined in base.css :root. */
:root {
--dd-footer-height: 30px;
--dd-footer-height: 40px;
--dd-sidebar-width: 320px;
--dd-gap: 8px;
}
@@ -375,6 +375,7 @@ body.dd-sidebar-open .dd-sidebar {
}
.dd-new-session .lt-input { grid-column: 1 / -1; font-size: 0.74rem; }
.dd-new-session .lt-select { min-width: 0; font-size: 0.72rem; }
.dd-new-session .dd-check { grid-column: 1 / -1; }
.dd-status.is-error { color: var(--accent-red); }
@@ -492,7 +493,23 @@ body.dd-sidebar-open .dd-sidebar {
overflow: hidden;
}
.dd-footer .lt-footer-key { margin-right: 4px; }
.dd-footer .lt-footer-key { margin-right: 5px; font-size: 0.86rem; }
/* devdesk#41: the bottom row was too small to read at a glance */
.dd-footer .lt-footer-hint { font-size: 0.9rem; }
.dd-footer .lt-footer-sep { font-size: 0.9rem; }
/* Narrower windows keep the usage meters and drop the least-used hints. */
@media (max-width: 1700px) {
.dd-footer .lt-footer-hints .lt-footer-hint:nth-child(n + 13),
.dd-footer .lt-footer-sep:nth-child(n + 12) { display: none; }
}
@media (max-width: 1500px) {
.dd-footer .lt-footer-hints .lt-footer-hint:nth-child(n + 11),
.dd-footer .lt-footer-sep:nth-child(n + 10) { display: none; }
}
@media (max-width: 1250px) {
.dd-footer .lt-footer-hints .lt-footer-hint:nth-child(n + 7),
.dd-footer .lt-footer-sep:nth-child(n + 6) { display: none; }
}
.dd-keybar { display: flex; gap: 4px; width: 100%; overflow-x: auto; scrollbar-width: none; }
.dd-keybar[hidden] { display: none; }
@@ -625,7 +642,7 @@ html[data-theme="light"] .lt-cmd-item.is-selected { background: var(--accent-cya
background: none;
color: var(--accent-cyan);
font-family: var(--font-mono);
font-size: 0.64rem;
font-size: 0.8rem;
cursor: pointer;
}
.dd-footer-help:hover { border-color: var(--accent-cyan); }
@@ -1005,11 +1022,25 @@ html[data-theme="light"] .dd-files.is-narrow .dd-explorer { box-shadow: 8px 0 24
.dd-history-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 8px; }
.dd-history-actions .lt-input { width: 220px; font-size: 0.74rem; }
.dd-footer-usage { margin-left: auto; padding-left: 12px; }
.dd-footer-usage { margin-left: auto; padding-left: 16px; }
.dd-footer-usage[hidden],
.dd-usage-row[hidden] { display: none; }
.dd-usage { display: inline-flex; align-items: center; gap: 10px; }
.dd-footer-usage { display: inline-flex; align-items: center; gap: 18px; }
.dd-meter { display: inline-flex; align-items: center; gap: 5px; color: var(--text-secondary); font-size: 0.66rem; white-space: nowrap; }
.dd-footer-usage .dd-meter { gap: 7px; color: var(--text-primary); font-family: var(--font-mono); font-size: 1rem; font-weight: 600; }
.dd-footer-usage .dd-meter-label { font-weight: 400; }
.dd-footer-usage .dd-meter-bar { width: 80px; height: 8px; }
.dd-meter-reset { color: var(--text-muted); font-weight: 400; }
.dd-meter-pace { color: var(--accent-amber); }
/* Turn timer in the session chip (devdesk#48) */
.dd-turn-timer { margin-left: 6px; color: var(--accent-amber); font-family: var(--font-mono); font-size: 0.85em; }
.dd-turn-timer[hidden] { display: none; }
.dd-session-actions .dd-compact { color: var(--accent-amber); }
.dd-wt { color: var(--accent-cyan); }
.dd-check { display: flex; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 0.74rem; cursor: pointer; }
.dd-check[hidden] { display: none; }
.dd-check input { accent-color: var(--accent-cyan); }
.dd-meter-label { color: var(--text-muted); }
.dd-meter-bar { width: 42px; height: 4px; background: var(--bg-tertiary); }
.dd-meter-fill { display: block; height: 100%; background: var(--accent-cyan); }
@@ -1022,3 +1053,13 @@ body.dd-touch .dd-footer-usage { display: none; }
.dd-pr { margin-left: 6px; padding: 0 4px; border: 1px solid var(--border-color); color: var(--text-secondary); font-size: 0.58rem; font-weight: 400; text-decoration: none; vertical-align: middle; }
.dd-pr.is-approved { border-color: var(--accent-green-border); color: var(--accent-green); }
.dd-pr.is-changes_requested { border-color: rgba(255, 45, 85, 0.4); color: var(--accent-red); }
/* Repo panel: changed files open as diffs (devdesk#46) */
.dd-repo-changes { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 1px; }
.dd-repo-changes[hidden] { display: none; }
.dd-repo-changes .dd-muted { font-size: 0.68rem; padding: 2px 6px; }
.dd-repo-change { display: flex; width: 100%; gap: 8px; align-items: baseline; padding: 3px 6px; border: 0; background: none; color: var(--text-secondary); font-family: var(--font-mono); font-size: 0.72rem; text-align: left; cursor: pointer; }
.dd-repo-change:hover, .dd-repo-change:focus-visible { background: var(--bg-tertiary); color: var(--accent-cyan); }
.dd-repo-change-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dd-repo-change-stat { flex: none; color: var(--text-muted); }
.dd-link-btn { padding: 0; border: 0; background: none; color: var(--accent-cyan); font: inherit; text-decoration: underline dotted; cursor: pointer; }