From be9b026a01efe6d5e2900550a5cd07eb2da21387 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 7 Oct 2026 23:34:59 -0400 Subject: [PATCH] feat: v0.5.0 header, switcher, phone key bar, shared polling, Matrix "needs you" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Header: no SSO chip ("Sign in again" only on expiry), state dot on the session chip, LIVE as a dot, ⋯ menu for text size/notifications/fullscreen, Panels badge for waiting sessions and alerts. - Session switcher on the chip; bare URL reattaches the last session; + New opens the panel form with project folders. - Notifications when other sessions start waiting; alerts polled with the panels closed; one tab polls for all tabs over BroadcastChannel. - Sidebar: context bar per session instead of the Context panel; compact Repo/CI with "Ask Claude to fix CI"; legible labels. - Phone: key bar, drawer with backdrop, visualViewport-aware layout, DOM renderer on touch, compact connection dot. - devdesk-notify-matrix: one Matrix message per wait that lasts DEVDESK_MATRIX_DELAY seconds with nobody attached. Closes #8 Closes #13 Closes #14 Closes #15 Closes #16 Closes #17 Closes #28 Co-Authored-By: Claude Opus 5.5 --- Makefile | 2 +- README.md | 20 +- debian/changelog | 43 +++++ docs/DEPLOY.md | 22 ++- etc/env | 9 + libexec/devdesk-lib.sh | 3 + libexec/devdesk-notify-matrix | 41 ++++ libexec/devdesk-state-hook | 20 +- tests/attach.bats | 58 ++++++ web/index.template.html | 114 ++++++------ web/src/connection.js | 8 +- web/src/context.js | 51 ----- web/src/main.js | 342 ++++++++++++++++++++++++++-------- web/src/notify.js | 20 +- web/src/repo.js | 27 +-- web/src/sessions.js | 61 ++++-- web/src/shared.js | 31 +++ web/src/widgets.js | 7 +- web/styles.css | 216 +++++++++++++++++---- 19 files changed, 838 insertions(+), 257 deletions(-) create mode 100755 libexec/devdesk-notify-matrix delete mode 100644 web/src/context.js create mode 100644 web/src/shared.js diff --git a/Makefile b/Makefile index 9417b82..f97aa6c 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,7 @@ install: all install -m 0644 libexec/devdesk-lib.sh $(DESTDIR)$(LIBDIR)/devdesk-lib.sh install -m 0755 libexec/devdesk-picker libexec/devdesk-claude-run libexec/devdesk-session-hook \ libexec/devdesk-tmux-ready libexec/devdesk-serve libexec/devdesk-statusline \ - libexec/devdesk-deploy libexec/devdesk-state-hook $(DESTDIR)$(LIBDIR)/ + libexec/devdesk-deploy libexec/devdesk-state-hook libexec/devdesk-notify-matrix $(DESTDIR)$(LIBDIR)/ install -m 0755 build/ttyd $(DESTDIR)$(LIBDIR)/bin/ttyd install -m 0644 dist/index.html share/tmux.conf share/claude-settings.json $(DESTDIR)$(SHAREDIR)/ install -m 0644 etc/env $(DESTDIR)$(SYSCONFDIR)/devdesk/env diff --git a/README.md b/README.md index b657266..8038926 100644 --- a/README.md +++ b/README.md @@ -28,22 +28,25 @@ The tmux server runs in its own unit, so every Claude process lives in **that** - `main` is the projects root. - **Opening one:** - `https://dev.lotusguild.org/?arg=` opens or creates that session. - - With no `?arg=`, an fzf picker opens, listing running (●), dormant (○) and new (+) sessions. - - The **Sessions** panel's form takes any name plus an optional project folder. + - 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. - **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**. - - The sidebar shows it with context %, and the header shows the current session's state. - - A pulsing "… needs you" chip jumps to other sessions that are waiting. + - 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. With the panels closed, the **Panels** button shows a count of waiting sessions and Grafana alerts. +- **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). - **File & image drop:** drag files onto the terminal or paste a screenshot. - They're uploaded through ttyd to `/var/lib/devdesk/uploads//` (25 MB max, pruned after 7 days), and the path is pasted into Claude. - For images, Claude attaches them itself. - **Ops Alerts panel:** firing and pending Grafana alert rules, each with a link to the rule. - **Ask Claude** types an investigation prompt into the current session without sending it. - Configure `DEVDESK_GRAFANA_URL`, plus a Viewer token in `DEVDESK_GRAFANA_TOKEN_FILE`. -- **Repo / CI panel:** branch, ahead/behind, uncommitted changes, HEAD's Gitea CI status and open PRs for the session's folder. -- **Claude Context panel:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use, cost and edits to `/var/lib/devdesk/context/.json`. The panel shows the current session's numbers, and Claude's own footer shows a one-line summary. Sessions started before 0.2.0 need Claude restarted (`/exit`, then ⏎ in the menu) before they report. +- **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. +- **Context:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use and cost to `/var/lib/devdesk/context/.json`. The Sessions panel shows it as a bar (cost in the tooltip), and Claude's own footer shows a one-line summary. +- **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 `, `devdesk new [project]`, `devdesk rename `, `devdesk end `, `devdesk ls`, `devdesk pick`, `devdesk repo `, `devdesk alerts`. ## Keys @@ -57,7 +60,7 @@ The tmux server runs in its own unit, so every Claude process lives in **that** | Shift+drag | Native browser selection (plain drag goes to tmux copy-mode, which copies via OSC 52) | | Ctrl+Shift+C / V | Copy selection / paste | -Use the **Full** button for fullscreen. In Chromium it also captures Ctrl+W/Ctrl+T, which browsers otherwise keep for themselves. +Text size, desktop notifications (with a mute) and fullscreen are in the **⋯** menu. 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`. @@ -66,6 +69,7 @@ Use the **Full** button for fullscreen. In Chromium it also captures Ctrl+W/Ctrl - `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 - `/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`. @@ -84,4 +88,4 @@ CI (`.gitea/workflows/build-deb.yml`): - A `v*` tag also publishes to `https://code.lotusguild.org/api/packages/LotusGuild/debian`, creates a release, and auto-deploys to CT 126. A signed webhook runs `devdesk-deploy`, which upgrades the package; see [docs/DEPLOY.md](docs/DEPLOY.md). ## Roadmap -- Matrix "Claude needs you" notifications when no tab is open. +Tracked in [Gitea issues](https://code.lotusguild.org/LotusGuild/devdesk/issues) by milestone: v0.6.0 file viewer, v0.7.0 editor + inbox, backlog. diff --git a/debian/changelog b/debian/changelog index 45a9e37..ffd80f9 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,46 @@ +devdesk (0.5.0) bookworm; urgency=medium + + * Security (from the 2026-10-07 review): + - ttyd :7681 is firewalled to NPM in CT 126 and its credential was + rotated (#1). + - "Ask Claude" strips control characters from Grafana text and pastes it + as bracketed paste, so alert text can never run a command (#2). + - OSC 52 clipboard is write-only: terminal output can no longer read the + browser clipboard; tmux allow-passthrough is off (#7). + - Gitea/Grafana tokens reach curl on stdin, never on its command line; + remote URLs lose any userinfo (#11). + * Fixes: + - Ending the session you are in no longer leaves a ghost entry (#3). + - Reconnecting after a drop never re-creates a session that was ended or + renamed meanwhile (`attach --existing`) (#4). + - Repo panel uses git --no-optional-locks and drops the safe.directory + override (#5); uploads get unique names and are cleaned up on hangup + (#6); uploads are size-checked first and streamed in slices with an + idle timeout (#9); paths with tabs no longer break the session list + (#10). + - ttyd allows 32 clients; side-channel calls run two at a time and one + tab polls for all open tabs (#8). + * Header: the SSO chip is gone ("Sign in again" appears only when the + session expires); the session chip shows Claude's state as a dot and + opens a session switcher; LIVE is a dot; text size, notifications (with + mute) and fullscreen move into a ⋯ menu; the Panels button shows a badge + for waiting sessions and alerts (#13, #16). + * A bare URL reattaches your last session instead of opening the picker + (#16). + * Notifications when *other* sessions start waiting, and alerts are polled + with the panels closed (#15). + * Sidebar: the Claude Context panel is folded into a context bar in each + session row; Repo/CI collapses for non-git folders and offers "Ask Claude + to fix CI"; the new-session form hides behind "+ New"; more legible + labels (#17). + * Phone: key bar (Esc, Tab, ⇧Tab, ^C, arrows, ⏎, Claude/Shell), drawer + with backdrop, layout that follows the on-screen keyboard, DOM renderer + on touch devices, no clock in the tmux status line (#14). + * Matrix "Claude needs you": a wait that lasts 30 s with nobody attached + posts one message with a link to the Devdesk hookshot webhook (#28). + + -- Jared Vititoe Wed, 07 Oct 2026 23:34:48 -0400 + devdesk (0.4.0) bookworm; urgency=medium * Session status at a glance: Claude hooks record whether each session is diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 5493cb4..e7f0bec 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -59,8 +59,26 @@ A `v*` tag makes CI publish the package, then send a signed webhook (the house H 3. Run `systemctl restart webhook`. 4. Store the same secret as the devdesk repo's Actions secret `WEBHOOK_SECRET`. -## 6. Optional hardening -Enable the Proxmox firewall on CT 126 (`firewall=1` on net0) and allow tcp/7681 only from 10.10.10.27. The same rule suits code-server on :8680. +## 6. Firewall (required) +The ttyd credential is the only thing between the LAN and a root shell, so only NPM may reach :7681. CT 126 ships this in pve-infra (`containers/126-codingenvironment/etc/nftables.conf`, installed by its `deploy.sh`): + +``` +table inet devdesk { + chain input { + type filter hook input priority filter - 10; policy accept; + tcp dport 7681 ip saddr { 127.0.0.1, 10.10.10.27 } accept + tcp dport 7681 ip6 saddr ::1 accept + tcp dport 7681 counter reject with tcp reset + } +} +``` + +Check from another LAN host: `curl -m3 http://10.10.10.58:7681/` must fail with "connection refused". To rotate the credential: `rm /etc/devdesk/secret.env && dpkg-reconfigure devdesk`, put the new `Authorization` value in NPM proxy host 58's Advanced tab, then `systemctl restart devdesk`. + +## 7. Matrix "Claude needs you" (optional) +1. Deploy a hookshot transform named `Devdesk` (`hookshot/devdesk.js` in the matrix repo; pushing it to `main` auto-deploys it on LXC 151). Look up the hook UUID as described in that repo's README. +2. `umask 077; echo https://matrix.lotusguild.org/webhook/ > /etc/devdesk/matrix-webhook`. +3. Nothing to restart: the next wait that lasts `DEVDESK_MATRIX_DELAY` seconds with nobody attached posts a message. ## Operations | Task | Command | diff --git a/etc/env b/etc/env index 32e3718..aacacbc 100644 --- a/etc/env +++ b/etc/env @@ -33,5 +33,14 @@ DEVDESK_GRAFANA_TOKEN_FILE=/etc/devdesk/grafana-token CLAUDE_BIN=/root/.local/bin/claude CLAUDE_ARGS= +# Where DevDesk is reached in a browser; used for links in Matrix notifications. +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. +# DEVDESK_MATRIX_DELAY: seconds the wait must last before posting. +DEVDESK_MATRIX_WEBHOOK_FILE=/etc/devdesk/matrix-webhook +DEVDESK_MATRIX_DELAY=30 + # 1 = also enable Remote Control so sessions can be continued from the Claude app. DEVDESK_REMOTE_CONTROL=0 diff --git a/libexec/devdesk-lib.sh b/libexec/devdesk-lib.sh index 43e0371..b32175b 100644 --- a/libexec/devdesk-lib.sh +++ b/libexec/devdesk-lib.sh @@ -34,6 +34,9 @@ dd_load_env : "${DEVDESK_GRAFANA_PUBLIC_URL:=$DEVDESK_GRAFANA_URL}" : "${DEVDESK_GRAFANA_TOKEN_FILE:=/etc/devdesk/grafana-token}" : "${DEVDESK_UPLOAD_MAX:=26214400}" +: "${DEVDESK_PUBLIC_URL:=}" +: "${DEVDESK_MATRIX_WEBHOOK_FILE:=/etc/devdesk/matrix-webhook}" +: "${DEVDESK_MATRIX_DELAY:=30}" DEVDESK_REGISTRY="$DEVDESK_STATE_DIR/registry.json" DEVDESK_CONTEXT_DIR="$DEVDESK_STATE_DIR/context" diff --git a/libexec/devdesk-notify-matrix b/libexec/devdesk-notify-matrix new file mode 100755 index 0000000..10f07a9 --- /dev/null +++ b/libexec/devdesk-notify-matrix @@ -0,0 +1,41 @@ +#!/bin/bash +# devdesk-notify-matrix SESSION SINCE +# +# 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). +set -euo pipefail + +# shellcheck source=libexec/devdesk-lib.sh +. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" + +session=${1:-} +since=${2:-} +dd_valid_name "$session" || exit 0 +[[ $since =~ ^[0-9]+$ ]] || exit 0 +[ -r "$DEVDESK_MATRIX_WEBHOOK_FILE" ] || exit 0 + +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 + +message=$(jq -rM '.message // "Claude is waiting for your input"' "$state_file" 2>/dev/null) +url="" +[ -z "$DEVDESK_PUBLIC_URL" ] || url="${DEVDESK_PUBLIC_URL%/}/?arg=$session" +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))}') + +# 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 diff --git a/libexec/devdesk-state-hook b/libexec/devdesk-state-hook index 9e87d93..8aa4eb5 100755 --- a/libexec/devdesk-state-hook +++ b/libexec/devdesk-state-hook @@ -6,6 +6,8 @@ # Stop, SessionStart -> idle # SessionEnd -> exited # Fires on every tool call, so it stays tiny and only writes when the state changes. +# Entering "waiting" also starts devdesk-notify-matrix, which posts to Matrix if +# the wait lasts and nobody is attached (devdesk#28). input=$(cat) @@ -29,12 +31,26 @@ file="$dir/$session.json" if [ "$state" != waiting ] && [ -f "$file" ] && grep -q "\"state\":\"$state\"" "$file"; then exit 0 fi +# A second Notification during the same wait updates the message but keeps +# `since`, so the wait stays one wait (one Matrix message at most). +since=$(date +%s) +was_waiting=0 +if [ "$state" = waiting ] && grep -q '"state":"waiting"' "$file" 2>/dev/null; then + was_waiting=1 + since=$(jq -rM '.since // empty' "$file" 2>/dev/null) || since=$(date +%s) + [[ $since =~ ^[0-9]+$ ]] || since=$(date +%s) +fi mkdir -p "$dir" 2>/dev/null || exit 0 tmp=$(mktemp "$dir/.$session.XXXXXX" 2>/dev/null) || exit 0 -if jq -cMn --arg s "$state" --arg m "$message" --arg e "$event" \ - '{state: $s, since: (now | floor), event: $e, message: (if $m == "" then null else $m end)}' >"$tmp"; then +if jq -cMn --arg s "$state" --arg m "$message" --arg e "$event" --argjson t "$since" \ + '{state: $s, since: $t, event: $e, message: (if $m == "" then null else $m end)}' >"$tmp"; then mv -f "$tmp" "$file" else rm -f "$tmp" + exit 0 +fi +if [ "$state" = waiting ] && [ "$was_waiting" = 0 ]; then + setsid -f "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-notify-matrix" "$session" "$since" \ + /dev/null 2>&1 || true fi exit 0 diff --git a/tests/attach.bats b/tests/attach.bats index d962a39..2d3ae6d 100644 --- a/tests/attach.bats +++ b/tests/attach.bats @@ -12,6 +12,7 @@ setup() { export DEVDESK_LIBEXEC="$REPO/libexec" export DEVDESK_SHARE="$REPO/share" export DEVDESK_NO_SYSTEMD=1 + export DEVDESK_MATRIX_WEBHOOK_FILE="$TMP/no-webhook" # never the real room export FAKE_LOG="$TMP/claude-args" export CLAUDE_BIN="$TMP/fake-claude" export PATH="$REPO/bin:$PATH" @@ -269,6 +270,63 @@ claude_args() { [ ! -e "$DEVDESK_STATE_DIR/state/beta.json" ] } +# Local stand-in for the hookshot webhook: appends each POST body to $TMP/posts. +start_receiver() { + python3 - "$TMP" <<'PY' & +import http.server, sys +tmp = sys.argv[1] +class H(http.server.BaseHTTPRequestHandler): + def do_POST(self): + body = self.rfile.read(int(self.headers['Content-Length'])) + with open(f'{tmp}/posts', 'ab') as f: + f.write(body + b'\n') + self.send_response(200); self.end_headers(); self.wfile.write(b'{"ok":true}') + def log_message(self, *a): + pass +srv = http.server.HTTPServer(('127.0.0.1', 0), H) +open(f'{tmp}/port', 'w').write(str(srv.server_port)) +srv.serve_forever() +PY + RECEIVER=$! + for _ in $(seq 1 50); do [ -s "$TMP/port" ] && break; sleep 0.1; done + printf 'http://127.0.0.1:%s/webhook/test\n' "$(cat "$TMP/port")" >"$TMP/webhook" + chmod 600 "$TMP/webhook" + export DEVDESK_MATRIX_WEBHOOK_FILE="$TMP/webhook" +} + +posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; } + +@test "a wait nobody is attached to is posted to Matrix once (#28)" { + 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"; } + + echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Bash"}' | hook + # a second notification in the same wait: no second message + echo '{"hook_event_name":"Notification","message":"Claude is waiting for your input"}' | 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\ttrue')" ] + + # answered within the delay: nothing is posted + echo '{"hook_event_name":"PostToolUse"}' | hook + echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Edit"}' | hook + echo '{"hook_event_name":"PostToolUse"}' | hook + sleep 2 + [ "$(posts)" -eq 1 ] + + # no webhook file: feature off + rm "$TMP/webhook" + echo '{"hook_event_name":"Notification","message":"again"}' | hook + sleep 2 + [ "$(posts)" -eq 1 ] + kill "$RECEIVER" +} + @test "alerts reports unconfigured and unreachable Grafana cleanly" { run env DEVDESK_GRAFANA_URL= devdesk alerts [ "$status" -eq 0 ] diff --git a/web/index.template.html b/web/index.template.html index 056212a..a3c11ab 100644 --- a/web/index.template.html +++ b/web/index.template.html @@ -2,7 +2,7 @@ - + @@ -16,27 +16,38 @@
DevDesk - Claude Code workspace
- — - + +
- + + connecting - -
- - - - - +
+ +
+
@@ -56,54 +67,24 @@
+
-
diff --git a/web/src/connection.js b/web/src/connection.js index 3998a9a..1a1244f 100644 --- a/web/src/connection.js +++ b/web/src/connection.js @@ -12,11 +12,17 @@ export async function fetchToken() { const res = await fetch(tokenUrl(window.location), { cache: 'no-store', credentials: 'same-origin', redirect: 'manual', }); - if (res.type === 'opaqueredirect') throw new AuthExpiredError('SSO session expired'); + if (res.type === 'opaqueredirect') { + // Any request can discover an expired Authelia session; the header shows + // "Sign in again" when this fires (devdesk#13). + window.dispatchEvent(new CustomEvent('devdesk:auth', { detail: 'expired' })); + throw new AuthExpiredError('SSO session expired'); + } if (res.status === 401 || res.status === 403 || res.status === 407) { throw new UnauthorizedError(`HTTP ${res.status}`); } if (!res.ok) throw new Error(`token request failed: HTTP ${res.status}`); + window.dispatchEvent(new CustomEvent('devdesk:auth', { detail: 'ok' })); const body = await res.json(); return body.token || ''; } diff --git a/web/src/context.js b/web/src/context.js deleted file mode 100644 index 0466f2d..0000000 --- a/web/src/context.js +++ /dev/null @@ -1,51 +0,0 @@ -// Claude Context panel: the current session's model, context-window use, cost -// and edits, from the snapshot devdesk-statusline writes on every Claude update. - -import { ago } from './sessions.js'; - -function compact(tokens) { - if (tokens == null) return '—'; - if (tokens >= 1e6) return `${+(tokens / 1e6).toFixed(tokens % 1e6 ? 1 : 0)}M`; - if (tokens >= 1e3) return `${Math.round(tokens / 1e3)}k`; - return String(tokens); -} - -export function renderContext(root, session, context) { - const set = (field, text) => { - const node = root.querySelector(`[data-field="${field}"]`); - if (node) node.textContent = text; - }; - const bar = root.querySelector('.lt-progress'); - const fill = root.querySelector('.lt-progress-bar'); - const note = root.querySelector('.dd-context-note'); - - if (!context) { - root.dataset.state = 'empty'; - fill.style.width = '0%'; - bar.setAttribute('aria-valuenow', '0'); - for (const f of ['used', 'model', 'cost', 'lines', 'updated']) set(f, '—'); - note.textContent = session - ? 'No data yet: it appears once Claude has redrawn its status line in this session.' - : 'Open a session to see its context.'; - return; - } - - const pct = context.used_pct; - root.dataset.state = 'live'; - fill.style.width = `${Math.min(100, pct ?? 0)}%`; - bar.setAttribute('aria-valuenow', String(Math.round(pct ?? 0))); - bar.classList.remove('lt-progress--cyan', 'lt-progress--red'); - if (pct == null || pct < 70) bar.classList.add('lt-progress--cyan'); - else if (pct >= 90) bar.classList.add('lt-progress--red'); - - set('used', pct == null - ? `— / ${compact(context.window)}` - : `${compact(context.used_tokens)} / ${compact(context.window)} (${pct.toFixed(pct < 10 ? 1 : 0)}%)`); - set('model', [context.model, context.effort].filter(Boolean).join(' · ') || '—'); - set('cost', context.cost_usd == null ? '—' : `$${context.cost_usd.toFixed(2)}`); - set('lines', `+${context.lines_added ?? 0} −${context.lines_removed ?? 0}`); - set('updated', context.updated ? `${ago(context.updated)} ago`.replace('now ago', 'just now') : '—'); - note.textContent = pct != null && pct >= 80 - ? 'Context is filling up; consider /compact or a new conversation.' - : ''; -} diff --git a/web/src/main.js b/web/src/main.js index f17332d..6369b38 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -4,11 +4,11 @@ import { TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError, } from './connection.js'; import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.js'; -import { SessionsPanel, rememberSession, sessionStatus } from './sessions.js'; +import { SessionsPanel, el, recentNames, rememberSession, sessionStatus } from './sessions.js'; import { renderAlerts } from './alerts.js'; -import { renderContext } from './context.js'; import { renderRepo } from './repo.js'; import { bindAskClaude } from './widgets.js'; +import { heardWithin, onShared, share } from './shared.js'; import * as notify from './notify.js'; const $ = (id) => document.getElementById(id); @@ -35,21 +35,27 @@ const ui = { body: document.body, wsStatus: $('ws-status'), wsLabel: $('ws-label'), - sso: $('sso-badge'), + reauth: $('reauth'), sessionChip: $('session-chip'), - stateChip: $('state-chip'), + sessionName: $('session-name'), + switcher: $('switcher'), attention: $('attention-chip'), paneTitle: $('pane-title'), overlay: $('overlay'), overlayTitle: $('overlay-title'), overlayText: $('overlay-text'), overlayActions: $('overlay-actions'), + moreToggle: $('more-toggle'), + moreMenu: $('more-menu'), notifyButton: $('notify-toggle'), sidebarButton: $('sidebar-toggle'), + panelsBadge: $('panels-badge'), + backdrop: $('backdrop'), }; +const urlSession = sessionFromSearch(window.location.search); const state = { - session: sessionFromSearch(window.location.search), // null = picker + session: urlSession, // null = picker conn: null, attempts: 0, retryTimer: null, @@ -58,23 +64,22 @@ const state = { lastActivity: Date.now(), }; +const touch = window.matchMedia('(pointer: coarse)').matches; +const isNarrow = () => !window.matchMedia('(min-width: 1100px)').matches; + let term; let fit; let sessionsPanel; +let lastAlerts = null; +let waitingOthers = []; +let paneTitleRaw = ''; // ---------------------------------------------------------------- status UI function setWsState(stateName, label) { ui.wsStatus.dataset.state = stateName; ui.wsLabel.textContent = label; -} - -function setSso(stateName) { - ui.sso.dataset.state = stateName; - ui.sso.textContent = stateName === 'expired' ? 'SSO expired' : 'SSO'; - ui.sso.title = stateName === 'expired' - ? 'Authelia session expired. Click to sign in again.' - : 'Signed in through Authelia'; + ui.wsStatus.title = label; } function showOverlay({ title, text, actions = [], tone = 'info' }) { @@ -96,25 +101,67 @@ function hideOverlay() { ui.overlay.hidden = true; } -function showContext() { - const data = sessionsPanel?.data; - const current = data?.sessions?.find((s) => s.name === state.session); - renderContext($('widget-context'), state.session, current?.context ?? null); - showSessionState(data, current); +// Pane title (Claude's own title) only when it says more than the session name. +function renderPaneTitle() { + const bare = paneTitleRaw.replace(/^[^\p{L}\p{N}]+/u, '').trim(); + ui.paneTitle.textContent = bare && bare !== state.session ? paneTitleRaw : ''; } -// Header: what Claude is doing here, and which other sessions are waiting on you. -function showSessionState(data, current) { +function renderSessionChip() { + const data = sessionsPanel?.data; + const current = data?.sessions?.find((s) => s.name === state.session); const status = current ? sessionStatus(current) : null; - const key = status && ['working', 'waiting', 'idle'].includes(status.key) ? status.key : ''; - ui.stateChip.dataset.state = key; - ui.stateChip.textContent = key ? status.label : ''; - ui.stateChip.title = current?.status?.message || ''; - const waiting = (data?.sessions ?? []).filter((s) => s.name !== state.session && sessionStatus(s).key === 'waiting'); - ui.attention.classList.toggle('is-active', waiting.length > 0); - ui.attention.textContent = waiting.length === 1 ? `${waiting[0].name} needs you` : `${waiting.length} need you`; - ui.attention.title = waiting.map((s) => `${s.name}: ${s.status?.message || 'waiting'}`).join('\n'); - ui.attention.dataset.target = waiting[0]?.name || ''; + ui.sessionName.textContent = state.session || 'picker'; + ui.sessionChip.dataset.state = status?.key ?? ''; + 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)}%` : '', + 'Click to switch session', + ].filter(Boolean).join('\n'); +} + +// Other sessions waiting on you: header chip, a notification when one starts +// waiting (devdesk#15), and the Panels badge. Only the tab that fetched the list +// notifies, so several open tabs don't ring once each. +let prevWaiting = new Set(); +function renderAttention(fetched) { + const data = sessionsPanel?.data; + waitingOthers = (data?.sessions ?? []).filter((s) => s.name !== state.session && sessionStatus(s).key === 'waiting'); + for (const s of waitingOthers) { + if (fetched && !prevWaiting.has(s.name)) notify.ring(s.name, s.status?.message || 'Claude needs your input'); + } + prevWaiting = new Set(waitingOthers.map((s) => s.name)); + ui.attention.classList.toggle('is-active', waitingOthers.length > 0); + ui.attention.textContent = waitingOthers.length === 1 ? `${waitingOthers[0].name} needs you` : `${waitingOthers.length} need you`; + ui.attention.title = waitingOthers.map((s) => `${s.name}: ${s.status?.message || 'waiting'}`).join('\n'); + ui.attention.dataset.target = waitingOthers[0]?.name || ''; + renderPanelsBadge(); +} + +function renderPanelsBadge() { + const alerts = lastAlerts?.alerts ?? []; + const count = waitingOthers.length + alerts.length; + const urgent = waitingOthers.length > 0 || alerts.some((a) => a.severity === 'critical'); + ui.panelsBadge.hidden = count === 0 || ui.body.classList.contains('dd-sidebar-open'); + ui.panelsBadge.textContent = String(count); + ui.panelsBadge.classList.toggle('is-urgent', urgent); + ui.panelsBadge.title = [ + waitingOthers.length ? `${waitingOthers.length} session(s) need you` : '', + alerts.length ? `${alerts.length} Grafana alert(s)` : '', + ].filter(Boolean).join(' · '); +} + +function onSessionData({ fetched = false } = {}) { + renderSessionChip(); + renderAttention(fetched); + if (!ui.switcher.hidden) renderSwitcher(); +} + +function showAlerts(data) { + lastAlerts = data; + renderAlerts($('widget-alerts'), data); + renderPanelsBadge(); } let alertsBusy = false; @@ -122,7 +169,8 @@ async function refreshAlerts() { if (alertsBusy) return; alertsBusy = true; try { - renderAlerts($('widget-alerts'), await fetchAlerts()); + showAlerts(await fetchAlerts()); + share('alerts', lastAlerts); } catch { // Keep the last rendering; the next refresh retries. } finally { @@ -254,8 +302,9 @@ function connect(session, { reconnect = false } = {}) { state.session = session; updateUrl(session); sessionsPanel?.setCurrent(session); - showContext(); - ui.sessionChip.textContent = session || 'picker'; + paneTitleRaw = ''; + renderPaneTitle(); + onSessionData(); hideOverlay(); setWsState('connecting', 'connecting'); state.openedAt = 0; @@ -268,7 +317,6 @@ function connect(session, { reconnect = false } = {}) { state.openedAt = Date.now(); state.attempts = 0; setWsState('connected', 'live'); - setSso('ok'); if (session) rememberSession(session); refreshRepo(); fit.fit(); @@ -323,12 +371,11 @@ function onClosed(code) { function onFailure(error) { if (error instanceof AuthExpiredError) { - setSso('expired'); setWsState('disconnected', 'signed out'); showOverlay({ title: 'Sign-in expired', text: 'Your Authelia session has expired. Claude keeps running on the server; sign in again to reattach.', - actions: [{ label: 'Re-authenticate', primary: true, run: () => window.location.reload() }], + actions: [{ label: 'Sign in again', primary: true, run: () => window.location.reload() }], tone: 'warn', }); return; @@ -373,12 +420,14 @@ function applyTitle() { titleFrame = 0; const parsed = parseTitle(pendingTitle); if (!parsed) { - ui.paneTitle.textContent = pendingTitle; + paneTitleRaw = pendingTitle; + renderPaneTitle(); return; } if (parsed.session === 'picker') { - ui.sessionChip.textContent = 'picker'; - ui.paneTitle.textContent = 'choose a session'; + ui.sessionName.textContent = 'picker'; + paneTitleRaw = 'choose a session'; + renderPaneTitle(); notify.setBaseTitle('DevDesk · picker'); return; } @@ -388,45 +437,152 @@ function applyTitle() { updateUrl(parsed.session); rememberSession(parsed.session); sessionsPanel.setCurrent(parsed.session); - showContext(); + onSessionData(); + refreshRepo(); } - ui.sessionChip.textContent = parsed.session; - ui.paneTitle.textContent = parsed.pane; + paneTitleRaw = parsed.pane; + renderPaneTitle(); notify.setBaseTitle(`${parsed.session} · DevDesk`); } +// ---------------------------------------------------------------- menus + +const menus = []; +function wireMenu(button, menu, onOpen) { + menus.push({ button, menu }); + button.addEventListener('click', (event) => { + event.stopPropagation(); + const open = menu.hidden; + closeMenus(); + if (open) { + onOpen?.(); + menu.hidden = false; + button.setAttribute('aria-expanded', 'true'); + } + }); + menu.addEventListener('click', (event) => event.stopPropagation()); +} + +function closeMenus() { + for (const { button, menu } of menus) { + menu.hidden = true; + button.setAttribute('aria-expanded', 'false'); + } +} + +// Session switcher on the session chip (devdesk#16). +function renderSwitcher() { + const sessions = [...(sessionsPanel?.data?.sessions ?? [])].sort((a, b) => + (b.running - a.running) || ((b.activity || 0) - (a.activity || 0)) || a.name.localeCompare(b.name)); + const choose = (run) => () => { + closeMenus(); + run(); + }; + const items = sessions.map((s) => { + const status = sessionStatus(s); + const ctx = s.context?.used_pct != null ? ` · ctx ${Math.floor(s.context.used_pct)}%` : ''; + return el('button', { + type: 'button', role: 'menuitem', + class: `dd-menu-item dd-switch-item is-${status.key}${s.name === state.session ? ' is-current' : ''}`, + title: s.dir || '', onclick: choose(() => connect(s.name)), + }, + el('span', { class: `lt-dot ${status.dot}` }), + el('span', { class: 'dd-switch-name' }, s.name), + el('span', { class: 'dd-switch-meta' }, `${status.label}${ctx}`)); + }); + if (!items.length) items.push(el('div', { class: 'dd-menu-note' }, sessionsPanel?.data ? 'No sessions yet.' : 'Loading…')); + ui.switcher.replaceChildren( + ...items, + el('div', { class: 'dd-menu-sep' }), + el('button', { + type: 'button', role: 'menuitem', class: 'dd-menu-item', + onclick: choose(() => { + setSidebar(true); + sessionsPanel.toggleForm(true); + }), + }, '+ New session…'), + el('button', { type: 'button', role: 'menuitem', class: 'dd-menu-item', onclick: choose(() => connect(null)) }, 'Open picker'), + ); +} + // ---------------------------------------------------------------- chrome controls function setFontSize(size) { const clamped = Math.min(24, Math.max(10, size)); term.options.fontSize = clamped; store.set('fontSize', clamped); + $('font-size').textContent = String(clamped); fit.fit(); } function renderNotifyButton() { - const labels = { granted: 'Alerts on', denied: 'Alerts blocked', default: 'Alerts off', unsupported: 'No alerts' }; 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.dataset.state = current; + ui.notifyButton.disabled = current === 'unsupported' || current === 'denied'; } function setSidebar(open) { ui.body.classList.toggle('dd-sidebar-open', open); ui.sidebarButton.setAttribute('aria-expanded', String(open)); - store.set('sidebar', open ? 'open' : 'closed'); - if (open) sessionsPanel?.refresh(); + ui.backdrop.hidden = !(open && isNarrow()); + if (!isNarrow()) store.set('sidebar', open ? 'open' : 'closed'); + if (open) { + sessionsPanel?.refresh(); + refreshAlerts(); + refreshRepo(); + } + renderPanelsBadge(); +} + +function closeDrawerIfNarrow() { + if (isNarrow() && ui.body.classList.contains('dd-sidebar-open')) setSidebar(false); +} + +// Phone key bar: keys an on-screen keyboard can't send (devdesk#14). +const KEYS = { + esc: '\x1b', tab: '\t', stab: '\x1b[Z', ctrlc: '\x03', up: '\x1b[A', down: '\x1b[B', enter: '\r', + claude: '\x020', shell: '\x021', // tmux prefix (C-b) + window number +}; + +function wireKeybar() { + if (!touch) return; + ui.body.classList.add('dd-touch'); + const bar = $('keybar'); + bar.hidden = false; + // Don't steal focus from the terminal (that would close the on-screen keyboard). + bar.addEventListener('pointerdown', (event) => event.preventDefault()); + bar.addEventListener('click', (event) => { + const key = event.target.closest('[data-keys]')?.dataset.keys; + if (key && KEYS[key]) state.conn?.sendInput(KEYS[key]); + }); + // Keep the terminal above the on-screen keyboard. + const vv = window.visualViewport; + if (vv) { + const update = () => { + const hidden = Math.max(0, window.innerHeight - vv.height - vv.offsetTop); + document.documentElement.style.setProperty('--dd-kb', `${hidden}px`); + }; + vv.addEventListener('resize', update); + vv.addEventListener('scroll', update); + } } 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 notify.requestNotifications(); + if (notify.notificationsState() === 'default') await notify.requestNotifications(); + else if (notify.notificationsState() === 'granted') notify.setMuted(!notify.isMuted()); renderNotifyButton(); }); - renderNotifyButton(); $('fullscreen-toggle').addEventListener('click', async () => { + closeMenus(); if (document.fullscreenElement) { await document.exitFullscreen(); return; @@ -435,13 +591,37 @@ function wireChrome() { // Chromium: capture Ctrl+W/Ctrl+T/Esc etc. for the terminal while fullscreen. navigator.keyboard?.lock?.().catch(() => {}); }); - ui.sidebarButton.addEventListener('click', () => setSidebar(!ui.body.classList.contains('dd-sidebar-open'))); - const wide = window.matchMedia('(min-width: 1100px)').matches; - setSidebar(store.get('sidebar', wide ? 'open' : 'closed') === 'open'); - - ui.sso.addEventListener('click', () => { - if (ui.sso.dataset.state === 'expired') window.location.reload(); + wireMenu(ui.moreToggle, ui.moreMenu, renderNotifyButton); + wireMenu(ui.sessionChip, ui.switcher, () => { + renderSwitcher(); + sessionsPanel.refresh(); }); + document.addEventListener('click', closeMenus); + + ui.sidebarButton.addEventListener('click', () => setSidebar(!ui.body.classList.contains('dd-sidebar-open'))); + ui.backdrop.addEventListener('click', () => setSidebar(false)); + setSidebar(!isNarrow() && store.get('sidebar', 'open') === 'open'); + window.matchMedia('(min-width: 1100px)').addEventListener('change', () => { + ui.backdrop.hidden = !(ui.body.classList.contains('dd-sidebar-open') && isNarrow()); + }); + + // Esc closes menus and the narrow-screen drawer before the terminal sees it. + document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') return; + const menuOpen = menus.some(({ menu }) => !menu.hidden); + const drawerOpen = isNarrow() && ui.body.classList.contains('dd-sidebar-open'); + if (!menuOpen && !drawerOpen) return; + event.preventDefault(); + event.stopPropagation(); + closeMenus(); + if (drawerOpen) setSidebar(false); + }, true); + + // "Sign in again" appears only when a request finds the SSO session expired (devdesk#13). + window.addEventListener('devdesk:auth', (event) => { + ui.reauth.hidden = event.detail !== 'expired'; + }); + ui.reauth.addEventListener('click', () => window.location.reload()); // Ctrl+W closes the tab before the terminal sees it; ask first. window.addEventListener('beforeunload', (event) => { @@ -455,27 +635,27 @@ function wireChrome() { document.addEventListener(type, () => { state.lastActivity = Date.now(); }, { capture: true, passive: true }); } // While you're active, touch the Authelia gate so its inactivity timer doesn't lapse. - setInterval(async () => { + setInterval(() => { if (Date.now() - state.lastActivity > 10 * 60 * 1000) return; - try { - await fetchToken(); - setSso('ok'); - } catch (error) { - if (error instanceof AuthExpiredError) setSso('expired'); - } + fetchToken().catch(() => {}); }, 3 * 60 * 1000); window.addEventListener('online', retrySoon); document.addEventListener('visibilitychange', () => { if (!document.hidden) retrySoon(); }); - // Session states feed the header chips too, so refresh even with the sidebar closed. + // 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)); setInterval(() => { - if (!document.hidden) sessionsPanel.refresh(); + if (!heardWithin('sessions', 16 * 1000)) sessionsPanel.refresh(); }, 15 * 1000); + // Alerts feed the Panels badge too. + onShared('alerts', showAlerts); setInterval(() => { - if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshAlerts(); - }, 30 * 1000); + if (!heardWithin('alerts', 62 * 1000)) refreshAlerts(); + }, 60 * 1000); // Repo status: on connect, after Claude's turns, and once a minute (devdesk#5, #8). setInterval(() => { if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshRepo(); @@ -485,11 +665,23 @@ function wireChrome() { }); } +// A bare URL (no ?arg=) reattaches the session you used last, or the most +// recently active one; the picker is only the fallback (devdesk#16). +function defaultSession(data) { + const sessions = data?.sessions ?? []; + const known = new Set(sessions.map((s) => s.name)); + const recent = recentNames().find((name) => known.has(name)); + if (recent) return recent; + const running = sessions.filter((s) => s.running).sort((a, b) => (b.activity || 0) - (a.activity || 0)); + return running[0]?.name ?? null; +} + // ---------------------------------------------------------------- boot async function boot() { $('version').textContent = `devdesk ${__DEVDESK_VERSION__}`; const fontSize = Math.min(24, Math.max(10, Number(store.get('fontSize', 14)) || 14)); + $('font-size').textContent = String(fontSize); await loadFonts(fontSize); ({ term, fit } = createTerminal($('terminal'), { @@ -504,8 +696,8 @@ async function boot() { if (!titleFrame) titleFrame = requestAnimationFrame(applyTitle); }); term.onBell(() => { - notify.ring(state.session, ui.paneTitle.textContent); - refreshSoon(); // Claude finished a turn: context numbers just changed + notify.ring(state.session, ui.paneTitle.textContent || 'Claude needs your attention'); + refreshSoon(); // Claude finished a turn: states and context just changed setTimeout(refreshRepo, 2000); // …and maybe committed or pushed }); @@ -526,11 +718,13 @@ async function boot() { nameInput: $('new-session-name'), select: $('project-select'), refreshButton: $('sessions-refresh'), - pickerButton: $('open-picker'), + newToggle: $('new-session-toggle'), status: $('sessions-status'), hooks: { - onOpen: (name) => connect(name), - onPicker: () => connect(null), + onOpen: (name) => { + closeDrawerIfNarrow(); + connect(name); + }, onEnding: () => { state.endingCurrent = true; }, onEnded: (name) => { if (name !== state.session) state.endingCurrent = false; @@ -541,23 +735,25 @@ async function boot() { state.session = to; updateUrl(to); rememberSession(to); - ui.sessionChip.textContent = to; sessionsPanel.setCurrent(to); + renderSessionChip(); }, - onData: () => showContext(), + onData: (_data, meta) => onSessionData(meta), }, }); bindAskClaude($('sidebar'), (text) => { // Alert text is untrusted: no control characters (a newline would run it in a // shell window, CR would submit), and bracketed paste around it (devdesk#2). term.paste(pasteSafe(text)); + closeDrawerIfNarrow(); term.focus(); }); wireChrome(); + wireKeybar(); wireUploads(); - sessionsPanel.refresh(); refreshAlerts(); - connect(state.session); + await sessionsPanel.refresh(); + connect(urlSession ?? defaultSession(sessionsPanel.data)); } boot(); diff --git a/web/src/notify.js b/web/src/notify.js index a223873..a5ee489 100644 --- a/web/src/notify.js +++ b/web/src/notify.js @@ -32,11 +32,29 @@ export async function requestNotifications() { return Notification.requestPermission(); } +const MUTE_KEY = 'devdesk.notify.muted'; + +export function isMuted() { + try { + return localStorage.getItem(MUTE_KEY) === '1'; + } catch { + return false; + } +} + +export function setMuted(muted) { + try { + localStorage.setItem(MUTE_KEY, muted ? '1' : '0'); + } catch { + // Private mode: the mute just won't persist. + } +} + export function ring(session, detail) { if (attentive()) return; unread += 1; render(); - if (notificationsState() === 'granted') { + if (notificationsState() === 'granted' && !isMuted()) { const note = new Notification(`Claude · ${session || 'devdesk'}`, { body: detail || 'Claude needs your attention', tag: `devdesk-${session}`, diff --git a/web/src/repo.js b/web/src/repo.js index d2baf13..9c24f61 100644 --- a/web/src/repo.js +++ b/web/src/repo.js @@ -21,21 +21,21 @@ function link(href, text) { export function renderRepo(root, repo) { 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; + // No repository: one line instead of a grid of dashes (devdesk#17). if (!repo || !repo.git) { root.dataset.state = 'empty'; - for (const f of ['repo', 'branch', 'dirty', 'prs']) set(f, '—'); - const ci = field('ci'); - ci.className = 'lt-status lt-status-pending'; - ci.textContent = 'no data'; - note.textContent = repo - ? `${repo.dir} is not a git repository.` - : 'Open a session to see its repository.'; + grid.hidden = true; + note.textContent = repo ? `Not a git repository: ${repo.dir}` : 'Open a session to see its repository.'; return; } root.dataset.state = 'live'; + grid.hidden = false; set('repo', repo.url ? link(repo.url, repo.repo) : (repo.remote || 'no remote')); const sync = repo.upstream ? [repo.ahead ? `↑${repo.ahead}` : '', repo.behind ? `↓${repo.behind}` : ''].filter(Boolean).join(' ') || 'in sync' @@ -45,12 +45,17 @@ export function renderRepo(root, repo) { const ci = field('ci'); const [label, cls] = repo.ci?.total ? (CI[repo.ci.state] || [repo.ci.state, 'lt-status-pending']) - : [repo.ahead ? 'not pushed' : 'no CI', 'lt-status-pending']; + : [repo.ahead ? 'not pushed' : 'no CI', '']; ci.className = `lt-status ${cls}`; ci.replaceChildren(repo.ci?.url ? link(repo.ci.url, label) : label); - set('prs', repo.url && repo.prs != null - ? link(`${repo.url}/pulls`, `${repo.prs} open`) - : '—'); + set('prs', repo.url && repo.prs != null ? link(`${repo.url}/pulls`, `${repo.prs} open`) : '—'); note.textContent = repo.repo ? '' : 'Not a Gitea repository; CI status unavailable.'; + + if (repo.ci?.total && ['failure', 'error'].includes(repo.ci.state)) { + fix.hidden = false; + fix.dataset.askClaude = `CI is failing for ${repo.repo} on ${repo.branch} at ${repo.sha}` + + ` (${repo.ci.url || 'see Gitea Actions'}). Use the gitea MCP server to read the failed job's log,` + + ' find the cause, and fix it. Tell me the plan before pushing anything.'; + } } diff --git a/web/src/sessions.js b/web/src/sessions.js index 6aee11f..90df4fa 100644 --- a/web/src/sessions.js +++ b/web/src/sessions.js @@ -3,6 +3,7 @@ import { fetchSessionList, sessionAction } from './connection.js'; import { slugify } from './protocol.js'; +import { share } from './shared.js'; const RECENTS_KEY = 'devdesk.recents'; const MAX_RECENTS = 12; @@ -24,6 +25,11 @@ function writeRecents(recents) { } } +// Session names this browser used most recently, newest first. +export function recentNames() { + return readRecents().map((r) => r.name); +} + export function rememberSession(name) { if (name) writeRecents([{ name, at: Date.now() }, ...readRecents().filter((r) => r.name !== name)]); } @@ -72,20 +78,26 @@ export function sessionStatus(s) { } export class SessionsPanel { - // hooks: onOpen(name), onPicker(), onEnded(name), onRenamed(from, to), onData(data) - constructor({ list, form, nameInput, select, refreshButton, pickerButton, status, hooks }) { - Object.assign(this, { list, nameInput, select, status, hooks }); + // 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 }); this.current = null; this.data = null; - this.busy = false; + this.pending = null; refreshButton.addEventListener('click', () => this.refresh()); - pickerButton.addEventListener('click', () => hooks.onPicker()); + newToggle.addEventListener('click', () => this.toggleForm()); form.addEventListener('submit', (event) => { event.preventDefault(); this.createFromForm(); }); } + toggleForm(open = this.form.hidden) { + this.form.hidden = !open; + this.newToggle.setAttribute('aria-expanded', String(open)); + if (open) this.nameInput.focus(); + } + setCurrent(name) { this.current = name; if (this.data) this.render(); @@ -96,19 +108,31 @@ export class SessionsPanel { this.status.classList.toggle('is-error', isError); } - async refresh() { - if (this.busy) return; - this.busy = true; + // Concurrent callers share the request in flight (boot awaits the first list). + refresh() { + this.pending ??= this.load().finally(() => { this.pending = null; }); + return this.pending; + } + + async load() { + let fetched = false; try { this.data = await fetchSessionList(); + fetched = true; + share('sessions', this.data); if (!this.status.classList.contains('is-error')) this.setStatus(''); } catch (err) { this.setStatus(`list unavailable: ${err.message}`, true); - } finally { - this.busy = false; } this.render(); - this.hooks.onData?.(this.data); + this.hooks.onData?.(this.data, { fetched }); + } + + // A list another tab fetched (devdesk#8). + accept(data) { + this.data = data; + this.render(); + this.hooks.onData?.(this.data, { fetched: false }); } async act(label, action, ...args) { @@ -135,6 +159,7 @@ export class SessionsPanel { if (project && !(await this.act('creating', 'new', name, project))) return; this.nameInput.value = ''; this.select.value = ''; + this.toggleForm(false); this.hooks.onOpen(name); } @@ -173,18 +198,26 @@ export class SessionsPanel { 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 model = isCurrent && s.context?.model ? s.context.model.replace(/^Claude\s+/i, '') : ''; const meta = s.running - ? [`${status.label}${since && since !== 'now' ? ` ${since}` : ''}`, ctx, s.attached ? `${s.attached} open` : ''] + ? [`${status.label}${since && since !== 'now' ? ` ${since}` : ''}`, ctx, model, s.attached ? `${s.attached} open` : ''] .filter(Boolean).join(' · ') : 'dormant · resumes Claude'; - const title = [s.dir, status.key === 'waiting' && s.status?.message].filter(Boolean).join('\n'); + const pct = s.context?.used_pct; + 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)}` : ''}`, + ].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); return el('li', { class: `dd-session-item is-${status.key}${isCurrent ? ' is-current' : ''}` }, 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), - el('span', { class: 'dd-session-meta' }, meta)), + el('span', { class: 'dd-session-meta' }, meta), + 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' }, iconButton('Rename', '✎', () => this.rename(s.name)), el('a', { diff --git a/web/src/shared.js b/web/src/shared.js new file mode 100644 index 0000000..54f9b94 --- /dev/null +++ b/web/src/shared.js @@ -0,0 +1,31 @@ +// One tab polls for every open DevDesk tab (devdesk#8): whoever fetches the +// session list or the alerts broadcasts the result, and a tab that heard a fresh +// result recently skips its own poll. Each poll costs a ttyd client and a PTY. + +const channel = typeof BroadcastChannel === 'function' ? new BroadcastChannel('devdesk') : null; +const heard = new Map(); +const handlers = new Map(); + +channel?.addEventListener('message', (event) => { + const { key, data } = event.data ?? {}; + const handler = handlers.get(key); + if (!handler) return; + heard.set(key, Date.now()); + handler(data); +}); + +export function onShared(key, handler) { + handlers.set(key, handler); +} + +export function share(key, data) { + try { + channel?.postMessage({ key, data }); + } catch { + // Not cloneable or channel closed: the other tabs just poll themselves. + } +} + +export function heardWithin(key, ms) { + return Date.now() - (heard.get(key) ?? 0) < ms; +} diff --git a/web/src/widgets.js b/web/src/widgets.js index 962d616..4fe699c 100644 --- a/web/src/widgets.js +++ b/web/src/widgets.js @@ -1,7 +1,6 @@ -// Sidebar widgets. In v1 only Sessions is live; Repo/CI and Claude Context get -// data from devdesk-api in v1.1, Ops Alerts in v2. The "Ask Claude" pattern is -// already wired: any [data-ask-claude] button types its prompt into the current -// session without pressing Enter, so you can review it before sending. +// "Ask Claude": any [data-ask-claude] button (Ops Alerts, Repo/CI) types its +// prompt into the current session without pressing Enter, so you can review it +// before sending. export function bindAskClaude(root, send) { root.addEventListener('click', (event) => { diff --git a/web/styles.css b/web/styles.css index 29e3a9d..e9ef85f 100644 --- a/web/styles.css +++ b/web/styles.css @@ -37,9 +37,12 @@ body.dd-app::after { } .dd-header .lt-header-left { + flex: 1 1 auto; min-width: 0; gap: var(--space-lg); } +.dd-header .lt-header-right { flex: 0 0 auto; } +.dd-session { flex: 0 1 auto; } .dd-session { display: flex; @@ -57,26 +60,115 @@ body.dd-app::after { max-width: 38vw; } -.dd-sso { - cursor: default; - background: transparent; +.dd-session-chip { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 6px; + max-width: 34vw; + padding: 3px 8px; + border: 1px solid var(--accent-cyan-border); + background: var(--accent-cyan-dim); + color: var(--accent-cyan); font-family: var(--font-mono); -} -.dd-sso[data-state="ok"] { color: var(--accent-green); background: var(--accent-green-dim); } -.dd-sso[data-state="unknown"] { color: var(--text-muted); } -.dd-sso[data-state="expired"] { - color: var(--accent-red); - background: var(--accent-red-dim); - text-shadow: var(--glow-red); + font-size: 0.78rem; + font-weight: 700; cursor: pointer; } +.dd-session-chip:hover, +.dd-session-chip[aria-expanded="true"] { border-color: var(--accent-cyan); box-shadow: var(--box-glow-cyan); } +.dd-session-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dd-caret { color: var(--text-muted); font-size: 0.7rem; } +/* Current session's Claude state as a dot on the chip (devdesk#13). */ +.dd-state-dot { flex: 0 0 auto; width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted); } +.dd-session-chip[data-state="working"] .dd-state-dot { background: var(--accent-amber); box-shadow: 0 0 6px var(--accent-amber); animation: lt-pulse 1.2s ease-in-out infinite; } +.dd-session-chip[data-state="waiting"] { border-color: rgba(255, 45, 85, 0.6); color: var(--accent-red); background: var(--accent-red-dim); } +.dd-session-chip[data-state="waiting"] .dd-state-dot { background: var(--accent-red); box-shadow: 0 0 6px var(--accent-red); animation: lt-pulse 1.2s ease-in-out infinite; } +.dd-session-chip[data-state="idle"] .dd-state-dot { background: var(--accent-green); } -.dd-tools .lt-btn[data-state="granted"] { color: var(--accent-green); border-color: var(--accent-green-border); } -.dd-tools .lt-btn[data-state="denied"] { color: var(--accent-red); } +/* Connection status: a dot; the word only when it isn't "live". */ +.dd-ws[data-state="connected"] #ws-label { display: none; } + +.dd-panels { position: relative; clip-path: none; } /* the corner cut hid the active border */ +@media (prefers-reduced-motion: reduce), (pointer: coarse) { + .dd-attention, + .dd-session-chip .dd-state-dot { animation: none !important; } +} +/* base.css draws keys in --text-dim (~1.5:1 on this background). */ +.dd-sidebar .lt-kv-key { color: var(--text-secondary); font-size: 0.7rem; } #sidebar-toggle[aria-expanded="true"] { color: var(--accent-orange); border-color: var(--accent-orange-border); } +.dd-badge { + margin-left: 6px; + min-width: 16px; + padding: 0 4px; + border-radius: 8px; + background: var(--accent-amber); + color: var(--bg-primary); + font-size: 0.62rem; + font-weight: 700; + line-height: 16px; + text-align: center; +} +.dd-badge.is-urgent { background: var(--accent-red); box-shadow: 0 0 8px var(--accent-red); } +.dd-badge[hidden] { display: none; } + +/* ---- menus (session switcher, ⋯) ----------------------------------------------- */ +.dd-session, +.dd-more { position: relative; } +.dd-more-toggle { font-size: 1rem; line-height: 1; padding-inline: 8px; } +.dd-menu { + position: absolute; + top: calc(100% + 6px); + z-index: 40; + min-width: 240px; + max-width: min(360px, calc(100vw - 16px)); + max-height: 70vh; + overflow-y: auto; + padding: 4px; + border: 1px solid var(--accent-cyan-border); + background: var(--bg-secondary); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.7), var(--box-glow-cyan); +} +.dd-menu[hidden] { display: none; } +.dd-switcher { left: 0; } +.dd-more-menu { right: 0; } +.dd-menu-item { + display: flex; + align-items: center; + gap: var(--space-sm); + width: 100%; + padding: 6px 8px; + background: none; + border: 1px solid transparent; + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 0.74rem; + text-align: left; + cursor: pointer; +} +.dd-menu-item:hover:not(:disabled), +.dd-menu-item:focus-visible { background: var(--accent-cyan-dim); color: var(--accent-cyan); } +.dd-menu-item:disabled { color: var(--text-muted); cursor: default; } +.dd-menu-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px; + font-size: 0.74rem; + color: var(--text-secondary); +} +.dd-menu-inline { display: inline-flex; align-items: center; gap: 4px; color: var(--text-primary); } +.dd-menu-sep { height: 1px; margin: 4px 0; background: var(--border-dim); } +.dd-menu-note { padding: 6px 8px; color: var(--text-muted); font-size: 0.72rem; } +.dd-menu-foot { padding: 6px 8px 2px; border-top: 1px solid var(--border-dim); margin-top: 4px; color: var(--text-muted); font-size: 0.62rem; } +.dd-switch-item.is-current { border-color: var(--accent-orange-border); background: var(--accent-orange-dim); } +.dd-switch-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; } +.dd-switch-meta { color: var(--text-muted); font-size: 0.64rem; white-space: nowrap; } +.dd-switch-item.is-waiting .dd-switch-meta { color: var(--accent-red); } +.dd-switch-item.is-working .dd-switch-meta { color: var(--accent-amber); } /* ---- layout ---------------------------------------------------------------- */ .dd-main { @@ -134,14 +226,8 @@ body.dd-sidebar-open .dd-sidebar { justify-content: flex-start; } -.dd-soon { - margin-left: auto; - color: var(--text-muted); - letter-spacing: 0.12em; -} - +.dd-header-actions { margin-left: auto; display: inline-flex; gap: var(--space-sm); } .dd-header-action { - margin-left: auto; background: none; border: 0; color: var(--accent-cyan); @@ -153,7 +239,7 @@ body.dd-sidebar-open .dd-sidebar { .dd-muted { color: var(--text-muted); - font-size: 0.68rem; + font-size: 0.7rem; } .dd-stub-note { @@ -193,7 +279,7 @@ body.dd-sidebar-open .dd-sidebar { text-align: left; } .dd-session-open:hover { background: var(--accent-cyan-dim); } -.dd-session-open .lt-dot { grid-row: 1 / span 2; } +.dd-session-open .lt-dot { grid-row: 1 / span 3; } .dd-session-name { font-size: 0.78rem; @@ -204,10 +290,22 @@ body.dd-sidebar-open .dd-sidebar { } .dd-session-meta { - font-size: 0.62rem; - color: var(--text-muted); + font-size: 0.68rem; + color: var(--text-secondary); } +/* Context-window fill under each session (devdesk#17). */ +.dd-ctx { + grid-column: 2; + height: 2px; + margin-top: 3px; + background: var(--bg-tertiary); + overflow: hidden; +} +.dd-ctx-fill { display: block; height: 100%; background: var(--accent-cyan); } +.dd-ctx.is-high .dd-ctx-fill { background: var(--accent-amber); } +.dd-ctx.is-full .dd-ctx-fill { background: var(--accent-red); } + .dd-session-item.is-recent .dd-session-name { color: var(--text-secondary); font-weight: 400; } .dd-session-item.is-dormant .dd-session-name { color: var(--text-secondary); } @@ -215,12 +313,17 @@ body.dd-sidebar-open .dd-sidebar { .dd-session-item.is-waiting .dd-session-meta { color: var(--accent-red); } .dd-session-item.is-working .dd-session-meta { color: var(--accent-amber); } -/* Header chips: current session's Claude state, and sessions needing you. */ -.dd-state-chip { display: none; } -.dd-state-chip[data-state="working"] { display: inline-flex; color: var(--accent-amber); background: var(--accent-amber-dim); } -.dd-state-chip[data-state="waiting"] { display: inline-flex; color: var(--accent-red); background: var(--accent-red-dim); text-shadow: var(--glow-red); } -.dd-state-chip[data-state="idle"] { display: inline-flex; color: var(--accent-green); background: var(--accent-green-dim); } +/* base.css sets display on .lt-btn/.lt-chip, which beats the [hidden] attribute. */ +.dd-header [hidden] { display: none !important; } + +/* Header chip: other sessions waiting on you. */ .dd-attention { + text-transform: none; + letter-spacing: 0; + max-width: 22vw; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; display: none; cursor: pointer; font-family: var(--font-mono); @@ -276,6 +379,7 @@ body.dd-sidebar-open .dd-sidebar { padding: 4px 6px; } +.dd-new-session[hidden] { display: none; } .dd-new-session { display: grid; grid-template-columns: 1fr auto; @@ -315,9 +419,11 @@ body.dd-sidebar-open .dd-sidebar { .dd-alert .dd-ask { align-self: flex-start; margin-top: 2px; } .dd-all-clear { display: flex; align-items: center; gap: var(--space-sm); color: var(--accent-green); font-size: 0.74rem; } -.dd-context-grid { margin-top: var(--space-sm); } - [data-state="stub"] .lt-section-body { opacity: 0.75; } +.dd-repo-grid[hidden] { display: none; } +.dd-repo-note:empty { display: none; } +.dd-fix-ci { margin-top: var(--space-sm); } +.dd-fix-ci[hidden] { display: none; } /* ---- file drop / upload ------------------------------------------------------ */ .dd-drop-overlay { @@ -398,6 +504,33 @@ body.dd-sidebar-open .dd-sidebar { .dd-footer .lt-footer-key { margin-right: 4px; } +.dd-keybar { display: flex; gap: 4px; width: 100%; overflow-x: auto; scrollbar-width: none; } +.dd-keybar[hidden] { display: none; } +.dd-keybar button { + flex: 1 1 auto; + min-width: 0; + height: 34px; + padding: 0 4px; + border: 1px solid var(--border-dim); + background: var(--bg-secondary); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 0.8rem; + touch-action: manipulation; +} +.dd-keybar button:active { background: var(--accent-cyan-dim); color: var(--accent-cyan); } + +/* Touch: the key bar replaces the shortcut hints; the page shrinks above the + on-screen keyboard (--dd-kb is set from visualViewport). */ +body.dd-touch { --dd-footer-height: 44px; } +body.dd-touch .dd-hints { display: none; } +body.dd-touch .dd-main { bottom: calc(var(--dd-footer-height) + var(--dd-kb, 0px) + env(safe-area-inset-bottom, 0px)); } +body.dd-touch .dd-footer { + bottom: var(--dd-kb, 0px); + height: calc(var(--dd-footer-height) + env(safe-area-inset-bottom, 0px)); + padding: 4px 4px calc(4px + env(safe-area-inset-bottom, 0px)); +} + /* ---- small screens ----------------------------------------------------------- */ @media (max-width: 1099px) { .dd-sidebar { @@ -405,7 +538,7 @@ body.dd-sidebar-open .dd-sidebar { top: var(--dd-gap); right: var(--dd-gap); bottom: var(--dd-gap); - width: min(var(--dd-sidebar-width), calc(100vw - 2 * var(--dd-gap))); + width: min(var(--dd-sidebar-width), calc(100vw - 56px)); /* leave a strip to tap out */ z-index: 20; background-color: var(--bg-secondary); box-shadow: -8px 0 32px rgba(0, 0, 0, 0.6); @@ -414,14 +547,25 @@ body.dd-sidebar-open .dd-sidebar { .dd-pane-title { display: none; } } +.dd-backdrop { + position: absolute; + inset: 0; + z-index: 15; + background: rgba(1, 3, 4, 0.55); +} +.dd-backdrop[hidden] { display: none; } + @media (max-width: 719px) { :root { --dd-gap: 4px; } - .lt-brand-subtitle, - #font-down, - #font-up, - #fullscreen-toggle, - .dd-sso { display: none; } + .lt-brand { display: none; } + .dd-session-chip { max-width: 46vw; } + /* Phones: the red Panels badge and the switcher carry "needs you" instead. */ + .dd-attention.is-active { display: none; } + .dd-header { padding: 0 var(--space-sm); gap: var(--space-sm); } .dd-header .lt-header-left { gap: var(--space-sm); } + /* base.css hides the WS indicator on phones; keep it as a bare dot. */ + .dd-header .dd-ws { display: inline-flex; padding: 4px; } + .dd-header .dd-ws #ws-label { display: none; } .dd-footer .lt-footer-hints .lt-footer-hint:nth-child(n + 5), .dd-footer .lt-footer-sep:nth-child(n + 4) { display: none; } }