feat: v0.5.0 header, switcher, phone key bar, shared polling, Matrix "needs you"
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -40,7 +40,7 @@ install: all
|
|||||||
install -m 0644 libexec/devdesk-lib.sh $(DESTDIR)$(LIBDIR)/devdesk-lib.sh
|
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 \
|
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-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 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 dist/index.html share/tmux.conf share/claude-settings.json $(DESTDIR)$(SHAREDIR)/
|
||||||
install -m 0644 etc/env $(DESTDIR)$(SYSCONFDIR)/devdesk/env
|
install -m 0644 etc/env $(DESTDIR)$(SYSCONFDIR)/devdesk/env
|
||||||
|
|||||||
@@ -28,22 +28,25 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
|
|||||||
- `main` is the projects root.
|
- `main` is the projects root.
|
||||||
- **Opening one:**
|
- **Opening one:**
|
||||||
- `https://dev.lotusguild.org/?arg=<name>` opens or creates that session.
|
- `https://dev.lotusguild.org/?arg=<name>` opens or creates that session.
|
||||||
- With no `?arg=`, an fzf picker opens, listing running (●), dormant (○) and new (+) sessions.
|
- 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.
|
||||||
- The **Sessions** panel's form takes any name plus an optional project folder.
|
- **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.
|
- **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).
|
- **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.
|
- **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**.
|
||||||
- The sidebar shows it with context %, and the header shows the current session's state.
|
- The sidebar shows it with context % and a context bar; the session chip's dot shows the current session's state.
|
||||||
- A pulsing "… needs you" chip jumps to other sessions that are waiting.
|
- 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.
|
- **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.
|
- 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.
|
- For images, Claude attaches them itself.
|
||||||
- **Ops Alerts panel:** firing and pending Grafana alert rules, each with a link to the rule.
|
- **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.
|
- **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`.
|
- 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.
|
- **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.
|
||||||
- **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/<session>.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.
|
- **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.
|
||||||
|
- **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]`, `devdesk rename <old> <new>`, `devdesk end <name>`, `devdesk ls`, `devdesk pick`, `devdesk repo <name>`, `devdesk alerts`.
|
||||||
|
|
||||||
## Keys
|
## 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) |
|
| Shift+drag | Native browser selection (plain drag goes to tmux copy-mode, which copies via OSC 52) |
|
||||||
| Ctrl+Shift+C / V | Copy selection / paste |
|
| 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
|
## Configuration
|
||||||
- **Put your changes in `/etc/devdesk/env.local`.** It's read after the packaged `env` and never shipped, so `apt upgrade` never asks about it. Example: `DEVDESK_REMOTE_CONTROL=1`.
|
- **Put your changes in `/etc/devdesk/env.local`.** It's read after the packaged `env` and never shipped, so `apt upgrade` never asks about it. Example: `DEVDESK_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`
|
- `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`
|
- `CLAUDE_BIN` and `CLAUDE_ARGS`
|
||||||
- `DEVDESK_REMOTE_CONTROL=1`, which also starts each session with Claude Remote Control so it can be continued from the Claude app
|
- `DEVDESK_REMOTE_CONTROL=1`, 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/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`.
|
- `/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).
|
- 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
|
## 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.
|
||||||
|
|||||||
Vendored
+43
@@ -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 <jjvititoe1@gmail.com> Wed, 07 Oct 2026 23:34:48 -0400
|
||||||
|
|
||||||
devdesk (0.4.0) bookworm; urgency=medium
|
devdesk (0.4.0) bookworm; urgency=medium
|
||||||
|
|
||||||
* Session status at a glance: Claude hooks record whether each session is
|
* Session status at a glance: Claude hooks record whether each session is
|
||||||
|
|||||||
+20
-2
@@ -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`.
|
3. Run `systemctl restart webhook`.
|
||||||
4. Store the same secret as the devdesk repo's Actions secret `WEBHOOK_SECRET`.
|
4. Store the same secret as the devdesk repo's Actions secret `WEBHOOK_SECRET`.
|
||||||
|
|
||||||
## 6. Optional hardening
|
## 6. Firewall (required)
|
||||||
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.
|
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/<uuid> > /etc/devdesk/matrix-webhook`.
|
||||||
|
3. Nothing to restart: the next wait that lasts `DEVDESK_MATRIX_DELAY` seconds with nobody attached posts a message.
|
||||||
|
|
||||||
## Operations
|
## Operations
|
||||||
| Task | Command |
|
| Task | Command |
|
||||||
|
|||||||
@@ -33,5 +33,14 @@ DEVDESK_GRAFANA_TOKEN_FILE=/etc/devdesk/grafana-token
|
|||||||
CLAUDE_BIN=/root/.local/bin/claude
|
CLAUDE_BIN=/root/.local/bin/claude
|
||||||
CLAUDE_ARGS=
|
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.
|
# 1 = also enable Remote Control so sessions can be continued from the Claude app.
|
||||||
DEVDESK_REMOTE_CONTROL=0
|
DEVDESK_REMOTE_CONTROL=0
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ dd_load_env
|
|||||||
: "${DEVDESK_GRAFANA_PUBLIC_URL:=$DEVDESK_GRAFANA_URL}"
|
: "${DEVDESK_GRAFANA_PUBLIC_URL:=$DEVDESK_GRAFANA_URL}"
|
||||||
: "${DEVDESK_GRAFANA_TOKEN_FILE:=/etc/devdesk/grafana-token}"
|
: "${DEVDESK_GRAFANA_TOKEN_FILE:=/etc/devdesk/grafana-token}"
|
||||||
: "${DEVDESK_UPLOAD_MAX:=26214400}"
|
: "${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_REGISTRY="$DEVDESK_STATE_DIR/registry.json"
|
||||||
DEVDESK_CONTEXT_DIR="$DEVDESK_STATE_DIR/context"
|
DEVDESK_CONTEXT_DIR="$DEVDESK_STATE_DIR/context"
|
||||||
|
|||||||
Executable
+41
@@ -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
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
# Stop, SessionStart -> idle
|
# Stop, SessionStart -> idle
|
||||||
# SessionEnd -> exited
|
# SessionEnd -> exited
|
||||||
# Fires on every tool call, so it stays tiny and only writes when the state changes.
|
# 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)
|
input=$(cat)
|
||||||
|
|
||||||
@@ -29,12 +31,26 @@ file="$dir/$session.json"
|
|||||||
if [ "$state" != waiting ] && [ -f "$file" ] && grep -q "\"state\":\"$state\"" "$file"; then
|
if [ "$state" != waiting ] && [ -f "$file" ] && grep -q "\"state\":\"$state\"" "$file"; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
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
|
mkdir -p "$dir" 2>/dev/null || exit 0
|
||||||
tmp=$(mktemp "$dir/.$session.XXXXXX" 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" \
|
if jq -cMn --arg s "$state" --arg m "$message" --arg e "$event" --argjson t "$since" \
|
||||||
'{state: $s, since: (now | floor), event: $e, message: (if $m == "" then null else $m end)}' >"$tmp"; then
|
'{state: $s, since: $t, event: $e, message: (if $m == "" then null else $m end)}' >"$tmp"; then
|
||||||
mv -f "$tmp" "$file"
|
mv -f "$tmp" "$file"
|
||||||
else
|
else
|
||||||
rm -f "$tmp"
|
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 >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ setup() {
|
|||||||
export DEVDESK_LIBEXEC="$REPO/libexec"
|
export DEVDESK_LIBEXEC="$REPO/libexec"
|
||||||
export DEVDESK_SHARE="$REPO/share"
|
export DEVDESK_SHARE="$REPO/share"
|
||||||
export DEVDESK_NO_SYSTEMD=1
|
export DEVDESK_NO_SYSTEMD=1
|
||||||
|
export DEVDESK_MATRIX_WEBHOOK_FILE="$TMP/no-webhook" # never the real room
|
||||||
export FAKE_LOG="$TMP/claude-args"
|
export FAKE_LOG="$TMP/claude-args"
|
||||||
export CLAUDE_BIN="$TMP/fake-claude"
|
export CLAUDE_BIN="$TMP/fake-claude"
|
||||||
export PATH="$REPO/bin:$PATH"
|
export PATH="$REPO/bin:$PATH"
|
||||||
@@ -269,6 +270,63 @@ claude_args() {
|
|||||||
[ ! -e "$DEVDESK_STATE_DIR/state/beta.json" ]
|
[ ! -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" {
|
@test "alerts reports unconfigured and unreachable Grafana cleanly" {
|
||||||
run env DEVDESK_GRAFANA_URL= devdesk alerts
|
run env DEVDESK_GRAFANA_URL= devdesk alerts
|
||||||
[ "$status" -eq 0 ]
|
[ "$status" -eq 0 ]
|
||||||
|
|||||||
+61
-53
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
|
||||||
<meta name="color-scheme" content="dark">
|
<meta name="color-scheme" content="dark">
|
||||||
<meta name="theme-color" content="#030508">
|
<meta name="theme-color" content="#030508">
|
||||||
<meta name="generator" content="devdesk">
|
<meta name="generator" content="devdesk">
|
||||||
@@ -16,27 +16,38 @@
|
|||||||
<div class="lt-header-left">
|
<div class="lt-header-left">
|
||||||
<div class="lt-brand">
|
<div class="lt-brand">
|
||||||
<span class="lt-brand-title">DevDesk</span>
|
<span class="lt-brand-title">DevDesk</span>
|
||||||
<span class="lt-brand-subtitle">Claude Code workspace</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="dd-session">
|
<div class="dd-session">
|
||||||
<span class="lt-chip lt-chip-info" id="session-chip">—</span>
|
<button type="button" class="dd-session-chip" id="session-chip" data-state="" aria-haspopup="menu" aria-expanded="false" title="Switch session">
|
||||||
<span class="lt-chip dd-state-chip" id="state-chip" data-state=""></span>
|
<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>
|
||||||
|
</button>
|
||||||
<span class="dd-pane-title" id="pane-title"></span>
|
<span class="dd-pane-title" id="pane-title"></span>
|
||||||
|
<div class="dd-menu dd-switcher" id="switcher" role="menu" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-header-right">
|
<div class="lt-header-right">
|
||||||
<button type="button" class="lt-chip dd-attention" id="attention-chip" title="Sessions waiting for you"></button>
|
<button type="button" class="lt-chip dd-attention" id="attention-chip" title="Sessions waiting for you"></button>
|
||||||
<span class="lt-ws-status" id="ws-status" data-state="connecting" role="status">
|
<button type="button" class="lt-btn lt-btn-danger lt-btn-sm dd-reauth" id="reauth" hidden title="Your Authelia session expired. Claude keeps running; sign in again to keep using the panels.">Sign in again</button>
|
||||||
|
<span class="lt-ws-status dd-ws" id="ws-status" data-state="connecting" role="status">
|
||||||
<span class="lt-dot"></span><span id="ws-label">connecting</span>
|
<span class="lt-dot"></span><span id="ws-label">connecting</span>
|
||||||
</span>
|
</span>
|
||||||
<button type="button" class="lt-chip dd-sso" id="sso-badge" data-state="unknown" title="Signed in through Authelia">SSO</button>
|
<div class="dd-more">
|
||||||
<div class="lt-btn-group dd-tools">
|
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm dd-more-toggle" id="more-toggle" aria-haspopup="menu" aria-expanded="false" title="Settings" aria-label="Settings">⋯</button>
|
||||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="font-down" title="Smaller text" aria-label="Smaller text">A−</button>
|
<div class="dd-menu dd-more-menu" id="more-menu" role="menu" hidden>
|
||||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="font-up" title="Larger text" aria-label="Larger text">A+</button>
|
<div class="dd-menu-row">
|
||||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="notify-toggle" title="Desktop notification when Claude needs you">Alerts off</button>
|
<span>Text size</span>
|
||||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="fullscreen-toggle" title="Fullscreen (also captures browser shortcuts in Chromium)">Full</button>
|
<span class="dd-menu-inline">
|
||||||
<button type="button" class="lt-btn lt-btn-sm" id="sidebar-toggle" aria-controls="sidebar" aria-expanded="false">Panels</button>
|
<button type="button" class="dd-icon-btn" id="font-down" aria-label="Smaller text">A−</button>
|
||||||
|
<span id="font-size">14</span>
|
||||||
|
<button type="button" class="dd-icon-btn" id="font-up" aria-label="Larger text">A+</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="dd-menu-item" id="notify-toggle" role="menuitem">Desktop notifications</button>
|
||||||
|
<button type="button" class="dd-menu-item" id="fullscreen-toggle" role="menuitem" title="In Chromium, fullscreen also captures Ctrl+W/Ctrl+T for the terminal">Fullscreen</button>
|
||||||
|
<div class="dd-menu-foot" id="version">devdesk</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="lt-btn lt-btn-sm dd-panels" id="sidebar-toggle" aria-controls="sidebar" aria-expanded="false">Panels<span class="dd-badge" id="panels-badge" hidden></span></button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -56,54 +67,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div class="dd-backdrop" id="backdrop" hidden></div>
|
||||||
<aside id="sidebar" class="dd-sidebar" aria-label="Panels">
|
<aside id="sidebar" class="dd-sidebar" aria-label="Panels">
|
||||||
<section class="lt-frame dd-widget" id="widget-sessions" data-state="live">
|
<section class="lt-frame dd-widget" id="widget-sessions" data-state="live">
|
||||||
<div class="lt-section-header">Sessions
|
<div class="lt-section-header">Sessions
|
||||||
<button type="button" class="dd-header-action" id="sessions-refresh" title="Refresh" aria-label="Refresh sessions">↻</button>
|
<span class="dd-header-actions">
|
||||||
|
<button type="button" class="dd-header-action" id="new-session-toggle" aria-expanded="false" title="New session">+ New</button>
|
||||||
|
<button type="button" class="dd-header-action" id="sessions-refresh" title="Refresh" aria-label="Refresh sessions">↻</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-section-body">
|
<div class="lt-section-body">
|
||||||
<ul class="dd-session-list" id="sessions-list"></ul>
|
<form class="dd-new-session" id="new-session-form" autocomplete="off" hidden>
|
||||||
<form class="dd-new-session" id="new-session-form" autocomplete="off">
|
<input class="lt-input" id="new-session-name" maxlength="64" placeholder="Name, e.g. pbs-upgrade"
|
||||||
<input class="lt-input" id="new-session-name" maxlength="64" placeholder="New session, e.g. pbs-upgrade"
|
|
||||||
aria-label="New session name" spellcheck="false">
|
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>
|
<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>
|
<button type="submit" class="lt-btn lt-btn-primary lt-btn-sm">Open</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="dd-widget-foot">
|
<ul class="dd-session-list" id="sessions-list"></ul>
|
||||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="open-picker">Picker</button>
|
<span class="dd-muted dd-status" id="sessions-status" role="status"></span>
|
||||||
<span class="dd-muted dd-status" id="sessions-status" role="status"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="lt-frame dd-widget" id="widget-context" data-state="empty">
|
|
||||||
<div class="lt-section-header">Claude Context</div>
|
|
||||||
<div class="lt-section-body">
|
|
||||||
<div class="lt-progress lt-progress--cyan" role="progressbar" aria-label="Context window used" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
|
|
||||||
<div class="lt-progress-bar" style="width:0%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="lt-kv-grid dd-context-grid">
|
|
||||||
<span class="lt-kv-key">Context</span><span class="lt-kv-val" data-field="used">—</span>
|
|
||||||
<span class="lt-kv-key">Model</span><span class="lt-kv-val" data-field="model">—</span>
|
|
||||||
<span class="lt-kv-key">Cost</span><span class="lt-kv-val" data-field="cost">—</span>
|
|
||||||
<span class="lt-kv-key">Edits</span><span class="lt-kv-val" data-field="lines">—</span>
|
|
||||||
<span class="lt-kv-key">Updated</span><span class="lt-kv-val" data-field="updated">—</span>
|
|
||||||
</div>
|
|
||||||
<p class="dd-muted dd-stub-note dd-context-note"></p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="lt-frame dd-widget" id="widget-repo" data-state="empty">
|
|
||||||
<div class="lt-section-header">Repo / CI</div>
|
|
||||||
<div class="lt-section-body">
|
|
||||||
<div class="lt-kv-grid">
|
|
||||||
<span class="lt-kv-key">Repo</span><span class="lt-kv-val" data-field="repo">—</span>
|
|
||||||
<span class="lt-kv-key">Branch</span><span class="lt-kv-val" data-field="branch">—</span>
|
|
||||||
<span class="lt-kv-key">Tree</span><span class="lt-kv-val" data-field="dirty">—</span>
|
|
||||||
<span class="lt-kv-key">CI · HEAD</span><span class="lt-kv-val"><span class="lt-status lt-status-pending" data-field="ci">no data</span></span>
|
|
||||||
<span class="lt-kv-key">Open PRs</span><span class="lt-kv-val" data-field="prs">—</span>
|
|
||||||
</div>
|
|
||||||
<p class="dd-muted dd-stub-note dd-repo-note"></p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -114,11 +95,26 @@
|
|||||||
<p class="dd-muted dd-stub-note dd-alerts-note"></p>
|
<p class="dd-muted dd-stub-note dd-alerts-note"></p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="lt-frame dd-widget" id="widget-repo" data-state="empty">
|
||||||
|
<div class="lt-section-header">Repo / CI</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
<div class="lt-kv-grid dd-repo-grid">
|
||||||
|
<span class="lt-kv-key">Repo</span><span class="lt-kv-val" data-field="repo">—</span>
|
||||||
|
<span class="lt-kv-key">Branch</span><span class="lt-kv-val" data-field="branch">—</span>
|
||||||
|
<span class="lt-kv-key">Tree</span><span class="lt-kv-val" data-field="dirty">—</span>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="lt-footer dd-footer">
|
<footer class="lt-footer dd-footer">
|
||||||
<div class="lt-footer-hints">
|
<div class="lt-footer-hints dd-hints">
|
||||||
<span class="lt-footer-hint"><span class="lt-footer-key">⇧⏎</span>newline</span>
|
<span class="lt-footer-hint"><span class="lt-footer-key">⇧⏎</span>newline</span>
|
||||||
<span class="lt-footer-sep">│</span>
|
<span class="lt-footer-sep">│</span>
|
||||||
<span class="lt-footer-hint"><span class="lt-footer-key">Esc</span>interrupt</span>
|
<span class="lt-footer-hint"><span class="lt-footer-key">Esc</span>interrupt</span>
|
||||||
@@ -130,8 +126,20 @@
|
|||||||
<span class="lt-footer-hint"><span class="lt-footer-key">^b d</span>detach</span>
|
<span class="lt-footer-hint"><span class="lt-footer-key">^b d</span>detach</span>
|
||||||
<span class="lt-footer-sep">│</span>
|
<span class="lt-footer-sep">│</span>
|
||||||
<span class="lt-footer-hint"><span class="lt-footer-key">⇧drag</span>select</span>
|
<span class="lt-footer-hint"><span class="lt-footer-key">⇧drag</span>select</span>
|
||||||
|
<span class="lt-footer-sep">│</span>
|
||||||
|
<span class="lt-footer-hint"><span class="lt-footer-key">drop / paste</span>attach files</span>
|
||||||
|
</div>
|
||||||
|
<div class="dd-keybar" id="keybar" role="toolbar" aria-label="Terminal keys" hidden>
|
||||||
|
<button type="button" data-keys="esc">Esc</button>
|
||||||
|
<button type="button" data-keys="tab">Tab</button>
|
||||||
|
<button type="button" data-keys="stab">⇧Tab</button>
|
||||||
|
<button type="button" data-keys="ctrlc">^C</button>
|
||||||
|
<button type="button" data-keys="up">↑</button>
|
||||||
|
<button type="button" data-keys="down">↓</button>
|
||||||
|
<button type="button" data-keys="enter">⏎</button>
|
||||||
|
<button type="button" data-keys="claude">Claude</button>
|
||||||
|
<button type="button" data-keys="shell">Shell</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="dd-muted" id="version">devdesk</span>
|
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<!--SCRIPT-->
|
<!--SCRIPT-->
|
||||||
|
|||||||
@@ -12,11 +12,17 @@ export async function fetchToken() {
|
|||||||
const res = await fetch(tokenUrl(window.location), {
|
const res = await fetch(tokenUrl(window.location), {
|
||||||
cache: 'no-store', credentials: 'same-origin', redirect: 'manual',
|
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) {
|
if (res.status === 401 || res.status === 403 || res.status === 407) {
|
||||||
throw new UnauthorizedError(`HTTP ${res.status}`);
|
throw new UnauthorizedError(`HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
if (!res.ok) throw new Error(`token request failed: 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();
|
const body = await res.json();
|
||||||
return body.token || '';
|
return body.token || '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.'
|
|
||||||
: '';
|
|
||||||
}
|
|
||||||
+269
-73
@@ -4,11 +4,11 @@ import {
|
|||||||
TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError,
|
TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError,
|
||||||
} from './connection.js';
|
} from './connection.js';
|
||||||
import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.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 { renderAlerts } from './alerts.js';
|
||||||
import { renderContext } from './context.js';
|
|
||||||
import { renderRepo } from './repo.js';
|
import { renderRepo } from './repo.js';
|
||||||
import { bindAskClaude } from './widgets.js';
|
import { bindAskClaude } from './widgets.js';
|
||||||
|
import { heardWithin, onShared, share } from './shared.js';
|
||||||
import * as notify from './notify.js';
|
import * as notify from './notify.js';
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
@@ -35,21 +35,27 @@ const ui = {
|
|||||||
body: document.body,
|
body: document.body,
|
||||||
wsStatus: $('ws-status'),
|
wsStatus: $('ws-status'),
|
||||||
wsLabel: $('ws-label'),
|
wsLabel: $('ws-label'),
|
||||||
sso: $('sso-badge'),
|
reauth: $('reauth'),
|
||||||
sessionChip: $('session-chip'),
|
sessionChip: $('session-chip'),
|
||||||
stateChip: $('state-chip'),
|
sessionName: $('session-name'),
|
||||||
|
switcher: $('switcher'),
|
||||||
attention: $('attention-chip'),
|
attention: $('attention-chip'),
|
||||||
paneTitle: $('pane-title'),
|
paneTitle: $('pane-title'),
|
||||||
overlay: $('overlay'),
|
overlay: $('overlay'),
|
||||||
overlayTitle: $('overlay-title'),
|
overlayTitle: $('overlay-title'),
|
||||||
overlayText: $('overlay-text'),
|
overlayText: $('overlay-text'),
|
||||||
overlayActions: $('overlay-actions'),
|
overlayActions: $('overlay-actions'),
|
||||||
|
moreToggle: $('more-toggle'),
|
||||||
|
moreMenu: $('more-menu'),
|
||||||
notifyButton: $('notify-toggle'),
|
notifyButton: $('notify-toggle'),
|
||||||
sidebarButton: $('sidebar-toggle'),
|
sidebarButton: $('sidebar-toggle'),
|
||||||
|
panelsBadge: $('panels-badge'),
|
||||||
|
backdrop: $('backdrop'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const urlSession = sessionFromSearch(window.location.search);
|
||||||
const state = {
|
const state = {
|
||||||
session: sessionFromSearch(window.location.search), // null = picker
|
session: urlSession, // null = picker
|
||||||
conn: null,
|
conn: null,
|
||||||
attempts: 0,
|
attempts: 0,
|
||||||
retryTimer: null,
|
retryTimer: null,
|
||||||
@@ -58,23 +64,22 @@ const state = {
|
|||||||
lastActivity: Date.now(),
|
lastActivity: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const touch = window.matchMedia('(pointer: coarse)').matches;
|
||||||
|
const isNarrow = () => !window.matchMedia('(min-width: 1100px)').matches;
|
||||||
|
|
||||||
let term;
|
let term;
|
||||||
let fit;
|
let fit;
|
||||||
let sessionsPanel;
|
let sessionsPanel;
|
||||||
|
let lastAlerts = null;
|
||||||
|
let waitingOthers = [];
|
||||||
|
let paneTitleRaw = '';
|
||||||
|
|
||||||
// ---------------------------------------------------------------- status UI
|
// ---------------------------------------------------------------- status UI
|
||||||
|
|
||||||
function setWsState(stateName, label) {
|
function setWsState(stateName, label) {
|
||||||
ui.wsStatus.dataset.state = stateName;
|
ui.wsStatus.dataset.state = stateName;
|
||||||
ui.wsLabel.textContent = label;
|
ui.wsLabel.textContent = label;
|
||||||
}
|
ui.wsStatus.title = 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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showOverlay({ title, text, actions = [], tone = 'info' }) {
|
function showOverlay({ title, text, actions = [], tone = 'info' }) {
|
||||||
@@ -96,25 +101,67 @@ function hideOverlay() {
|
|||||||
ui.overlay.hidden = true;
|
ui.overlay.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showContext() {
|
// Pane title (Claude's own title) only when it says more than the session name.
|
||||||
const data = sessionsPanel?.data;
|
function renderPaneTitle() {
|
||||||
const current = data?.sessions?.find((s) => s.name === state.session);
|
const bare = paneTitleRaw.replace(/^[^\p{L}\p{N}]+/u, '').trim();
|
||||||
renderContext($('widget-context'), state.session, current?.context ?? null);
|
ui.paneTitle.textContent = bare && bare !== state.session ? paneTitleRaw : '';
|
||||||
showSessionState(data, current);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header: what Claude is doing here, and which other sessions are waiting on you.
|
function renderSessionChip() {
|
||||||
function showSessionState(data, current) {
|
const data = sessionsPanel?.data;
|
||||||
|
const current = data?.sessions?.find((s) => s.name === state.session);
|
||||||
const status = current ? sessionStatus(current) : null;
|
const status = current ? sessionStatus(current) : null;
|
||||||
const key = status && ['working', 'waiting', 'idle'].includes(status.key) ? status.key : '';
|
ui.sessionName.textContent = state.session || 'picker';
|
||||||
ui.stateChip.dataset.state = key;
|
ui.sessionChip.dataset.state = status?.key ?? '';
|
||||||
ui.stateChip.textContent = key ? status.label : '';
|
ui.sessionChip.title = [
|
||||||
ui.stateChip.title = current?.status?.message || '';
|
state.session ? `Session ${state.session}${status ? ` · ${status.label}` : ''}` : 'Session picker',
|
||||||
const waiting = (data?.sessions ?? []).filter((s) => s.name !== state.session && sessionStatus(s).key === 'waiting');
|
current?.status?.message,
|
||||||
ui.attention.classList.toggle('is-active', waiting.length > 0);
|
current?.context?.used_pct != null ? `context ${Math.floor(current.context.used_pct)}%` : '',
|
||||||
ui.attention.textContent = waiting.length === 1 ? `${waiting[0].name} needs you` : `${waiting.length} need you`;
|
'Click to switch session',
|
||||||
ui.attention.title = waiting.map((s) => `${s.name}: ${s.status?.message || 'waiting'}`).join('\n');
|
].filter(Boolean).join('\n');
|
||||||
ui.attention.dataset.target = waiting[0]?.name || '';
|
}
|
||||||
|
|
||||||
|
// 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;
|
let alertsBusy = false;
|
||||||
@@ -122,7 +169,8 @@ async function refreshAlerts() {
|
|||||||
if (alertsBusy) return;
|
if (alertsBusy) return;
|
||||||
alertsBusy = true;
|
alertsBusy = true;
|
||||||
try {
|
try {
|
||||||
renderAlerts($('widget-alerts'), await fetchAlerts());
|
showAlerts(await fetchAlerts());
|
||||||
|
share('alerts', lastAlerts);
|
||||||
} catch {
|
} catch {
|
||||||
// Keep the last rendering; the next refresh retries.
|
// Keep the last rendering; the next refresh retries.
|
||||||
} finally {
|
} finally {
|
||||||
@@ -254,8 +302,9 @@ function connect(session, { reconnect = false } = {}) {
|
|||||||
state.session = session;
|
state.session = session;
|
||||||
updateUrl(session);
|
updateUrl(session);
|
||||||
sessionsPanel?.setCurrent(session);
|
sessionsPanel?.setCurrent(session);
|
||||||
showContext();
|
paneTitleRaw = '';
|
||||||
ui.sessionChip.textContent = session || 'picker';
|
renderPaneTitle();
|
||||||
|
onSessionData();
|
||||||
hideOverlay();
|
hideOverlay();
|
||||||
setWsState('connecting', 'connecting');
|
setWsState('connecting', 'connecting');
|
||||||
state.openedAt = 0;
|
state.openedAt = 0;
|
||||||
@@ -268,7 +317,6 @@ function connect(session, { reconnect = false } = {}) {
|
|||||||
state.openedAt = Date.now();
|
state.openedAt = Date.now();
|
||||||
state.attempts = 0;
|
state.attempts = 0;
|
||||||
setWsState('connected', 'live');
|
setWsState('connected', 'live');
|
||||||
setSso('ok');
|
|
||||||
if (session) rememberSession(session);
|
if (session) rememberSession(session);
|
||||||
refreshRepo();
|
refreshRepo();
|
||||||
fit.fit();
|
fit.fit();
|
||||||
@@ -323,12 +371,11 @@ function onClosed(code) {
|
|||||||
|
|
||||||
function onFailure(error) {
|
function onFailure(error) {
|
||||||
if (error instanceof AuthExpiredError) {
|
if (error instanceof AuthExpiredError) {
|
||||||
setSso('expired');
|
|
||||||
setWsState('disconnected', 'signed out');
|
setWsState('disconnected', 'signed out');
|
||||||
showOverlay({
|
showOverlay({
|
||||||
title: 'Sign-in expired',
|
title: 'Sign-in expired',
|
||||||
text: 'Your Authelia session has expired. Claude keeps running on the server; sign in again to reattach.',
|
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',
|
tone: 'warn',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -373,12 +420,14 @@ function applyTitle() {
|
|||||||
titleFrame = 0;
|
titleFrame = 0;
|
||||||
const parsed = parseTitle(pendingTitle);
|
const parsed = parseTitle(pendingTitle);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
ui.paneTitle.textContent = pendingTitle;
|
paneTitleRaw = pendingTitle;
|
||||||
|
renderPaneTitle();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (parsed.session === 'picker') {
|
if (parsed.session === 'picker') {
|
||||||
ui.sessionChip.textContent = 'picker';
|
ui.sessionName.textContent = 'picker';
|
||||||
ui.paneTitle.textContent = 'choose a session';
|
paneTitleRaw = 'choose a session';
|
||||||
|
renderPaneTitle();
|
||||||
notify.setBaseTitle('DevDesk · picker');
|
notify.setBaseTitle('DevDesk · picker');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -388,45 +437,152 @@ function applyTitle() {
|
|||||||
updateUrl(parsed.session);
|
updateUrl(parsed.session);
|
||||||
rememberSession(parsed.session);
|
rememberSession(parsed.session);
|
||||||
sessionsPanel.setCurrent(parsed.session);
|
sessionsPanel.setCurrent(parsed.session);
|
||||||
showContext();
|
onSessionData();
|
||||||
|
refreshRepo();
|
||||||
}
|
}
|
||||||
ui.sessionChip.textContent = parsed.session;
|
paneTitleRaw = parsed.pane;
|
||||||
ui.paneTitle.textContent = parsed.pane;
|
renderPaneTitle();
|
||||||
notify.setBaseTitle(`${parsed.session} · DevDesk`);
|
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
|
// ---------------------------------------------------------------- chrome controls
|
||||||
|
|
||||||
function setFontSize(size) {
|
function setFontSize(size) {
|
||||||
const clamped = Math.min(24, Math.max(10, size));
|
const clamped = Math.min(24, Math.max(10, size));
|
||||||
term.options.fontSize = clamped;
|
term.options.fontSize = clamped;
|
||||||
store.set('fontSize', clamped);
|
store.set('fontSize', clamped);
|
||||||
|
$('font-size').textContent = String(clamped);
|
||||||
fit.fit();
|
fit.fit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderNotifyButton() {
|
function renderNotifyButton() {
|
||||||
const labels = { granted: 'Alerts on', denied: 'Alerts blocked', default: 'Alerts off', unsupported: 'No alerts' };
|
|
||||||
const current = notify.notificationsState();
|
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.textContent = labels[current];
|
||||||
ui.notifyButton.dataset.state = current;
|
ui.notifyButton.disabled = current === 'unsupported' || current === 'denied';
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSidebar(open) {
|
function setSidebar(open) {
|
||||||
ui.body.classList.toggle('dd-sidebar-open', open);
|
ui.body.classList.toggle('dd-sidebar-open', open);
|
||||||
ui.sidebarButton.setAttribute('aria-expanded', String(open));
|
ui.sidebarButton.setAttribute('aria-expanded', String(open));
|
||||||
store.set('sidebar', open ? 'open' : 'closed');
|
ui.backdrop.hidden = !(open && isNarrow());
|
||||||
if (open) sessionsPanel?.refresh();
|
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() {
|
function wireChrome() {
|
||||||
$('font-down').addEventListener('click', () => setFontSize(term.options.fontSize - 1));
|
$('font-down').addEventListener('click', () => setFontSize(term.options.fontSize - 1));
|
||||||
$('font-up').addEventListener('click', () => setFontSize(term.options.fontSize + 1));
|
$('font-up').addEventListener('click', () => setFontSize(term.options.fontSize + 1));
|
||||||
ui.notifyButton.addEventListener('click', async () => {
|
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();
|
||||||
});
|
});
|
||||||
renderNotifyButton();
|
|
||||||
$('fullscreen-toggle').addEventListener('click', async () => {
|
$('fullscreen-toggle').addEventListener('click', async () => {
|
||||||
|
closeMenus();
|
||||||
if (document.fullscreenElement) {
|
if (document.fullscreenElement) {
|
||||||
await document.exitFullscreen();
|
await document.exitFullscreen();
|
||||||
return;
|
return;
|
||||||
@@ -435,13 +591,37 @@ function wireChrome() {
|
|||||||
// Chromium: capture Ctrl+W/Ctrl+T/Esc etc. for the terminal while fullscreen.
|
// Chromium: capture Ctrl+W/Ctrl+T/Esc etc. for the terminal while fullscreen.
|
||||||
navigator.keyboard?.lock?.().catch(() => {});
|
navigator.keyboard?.lock?.().catch(() => {});
|
||||||
});
|
});
|
||||||
ui.sidebarButton.addEventListener('click', () => setSidebar(!ui.body.classList.contains('dd-sidebar-open')));
|
wireMenu(ui.moreToggle, ui.moreMenu, renderNotifyButton);
|
||||||
const wide = window.matchMedia('(min-width: 1100px)').matches;
|
wireMenu(ui.sessionChip, ui.switcher, () => {
|
||||||
setSidebar(store.get('sidebar', wide ? 'open' : 'closed') === 'open');
|
renderSwitcher();
|
||||||
|
sessionsPanel.refresh();
|
||||||
ui.sso.addEventListener('click', () => {
|
|
||||||
if (ui.sso.dataset.state === 'expired') window.location.reload();
|
|
||||||
});
|
});
|
||||||
|
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.
|
// Ctrl+W closes the tab before the terminal sees it; ask first.
|
||||||
window.addEventListener('beforeunload', (event) => {
|
window.addEventListener('beforeunload', (event) => {
|
||||||
@@ -455,27 +635,27 @@ function wireChrome() {
|
|||||||
document.addEventListener(type, () => { state.lastActivity = Date.now(); }, { capture: true, passive: true });
|
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.
|
// 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;
|
if (Date.now() - state.lastActivity > 10 * 60 * 1000) return;
|
||||||
try {
|
fetchToken().catch(() => {});
|
||||||
await fetchToken();
|
|
||||||
setSso('ok');
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof AuthExpiredError) setSso('expired');
|
|
||||||
}
|
|
||||||
}, 3 * 60 * 1000);
|
}, 3 * 60 * 1000);
|
||||||
|
|
||||||
window.addEventListener('online', retrySoon);
|
window.addEventListener('online', retrySoon);
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (!document.hidden) retrySoon();
|
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(() => {
|
setInterval(() => {
|
||||||
if (!document.hidden) sessionsPanel.refresh();
|
if (!heardWithin('sessions', 16 * 1000)) sessionsPanel.refresh();
|
||||||
}, 15 * 1000);
|
}, 15 * 1000);
|
||||||
|
// Alerts feed the Panels badge too.
|
||||||
|
onShared('alerts', showAlerts);
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshAlerts();
|
if (!heardWithin('alerts', 62 * 1000)) refreshAlerts();
|
||||||
}, 30 * 1000);
|
}, 60 * 1000);
|
||||||
// Repo status: on connect, after Claude's turns, and once a minute (devdesk#5, #8).
|
// Repo status: on connect, after Claude's turns, and once a minute (devdesk#5, #8).
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshRepo();
|
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
|
// ---------------------------------------------------------------- boot
|
||||||
|
|
||||||
async function boot() {
|
async function boot() {
|
||||||
$('version').textContent = `devdesk ${__DEVDESK_VERSION__}`;
|
$('version').textContent = `devdesk ${__DEVDESK_VERSION__}`;
|
||||||
const fontSize = Math.min(24, Math.max(10, Number(store.get('fontSize', 14)) || 14));
|
const fontSize = Math.min(24, Math.max(10, Number(store.get('fontSize', 14)) || 14));
|
||||||
|
$('font-size').textContent = String(fontSize);
|
||||||
await loadFonts(fontSize);
|
await loadFonts(fontSize);
|
||||||
|
|
||||||
({ term, fit } = createTerminal($('terminal'), {
|
({ term, fit } = createTerminal($('terminal'), {
|
||||||
@@ -504,8 +696,8 @@ async function boot() {
|
|||||||
if (!titleFrame) titleFrame = requestAnimationFrame(applyTitle);
|
if (!titleFrame) titleFrame = requestAnimationFrame(applyTitle);
|
||||||
});
|
});
|
||||||
term.onBell(() => {
|
term.onBell(() => {
|
||||||
notify.ring(state.session, ui.paneTitle.textContent);
|
notify.ring(state.session, ui.paneTitle.textContent || 'Claude needs your attention');
|
||||||
refreshSoon(); // Claude finished a turn: context numbers just changed
|
refreshSoon(); // Claude finished a turn: states and context just changed
|
||||||
setTimeout(refreshRepo, 2000); // …and maybe committed or pushed
|
setTimeout(refreshRepo, 2000); // …and maybe committed or pushed
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -526,11 +718,13 @@ async function boot() {
|
|||||||
nameInput: $('new-session-name'),
|
nameInput: $('new-session-name'),
|
||||||
select: $('project-select'),
|
select: $('project-select'),
|
||||||
refreshButton: $('sessions-refresh'),
|
refreshButton: $('sessions-refresh'),
|
||||||
pickerButton: $('open-picker'),
|
newToggle: $('new-session-toggle'),
|
||||||
status: $('sessions-status'),
|
status: $('sessions-status'),
|
||||||
hooks: {
|
hooks: {
|
||||||
onOpen: (name) => connect(name),
|
onOpen: (name) => {
|
||||||
onPicker: () => connect(null),
|
closeDrawerIfNarrow();
|
||||||
|
connect(name);
|
||||||
|
},
|
||||||
onEnding: () => { state.endingCurrent = true; },
|
onEnding: () => { state.endingCurrent = true; },
|
||||||
onEnded: (name) => {
|
onEnded: (name) => {
|
||||||
if (name !== state.session) state.endingCurrent = false;
|
if (name !== state.session) state.endingCurrent = false;
|
||||||
@@ -541,23 +735,25 @@ async function boot() {
|
|||||||
state.session = to;
|
state.session = to;
|
||||||
updateUrl(to);
|
updateUrl(to);
|
||||||
rememberSession(to);
|
rememberSession(to);
|
||||||
ui.sessionChip.textContent = to;
|
|
||||||
sessionsPanel.setCurrent(to);
|
sessionsPanel.setCurrent(to);
|
||||||
|
renderSessionChip();
|
||||||
},
|
},
|
||||||
onData: () => showContext(),
|
onData: (_data, meta) => onSessionData(meta),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
bindAskClaude($('sidebar'), (text) => {
|
bindAskClaude($('sidebar'), (text) => {
|
||||||
// Alert text is untrusted: no control characters (a newline would run it in a
|
// 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).
|
// shell window, CR would submit), and bracketed paste around it (devdesk#2).
|
||||||
term.paste(pasteSafe(text));
|
term.paste(pasteSafe(text));
|
||||||
|
closeDrawerIfNarrow();
|
||||||
term.focus();
|
term.focus();
|
||||||
});
|
});
|
||||||
wireChrome();
|
wireChrome();
|
||||||
|
wireKeybar();
|
||||||
wireUploads();
|
wireUploads();
|
||||||
sessionsPanel.refresh();
|
|
||||||
refreshAlerts();
|
refreshAlerts();
|
||||||
connect(state.session);
|
await sessionsPanel.refresh();
|
||||||
|
connect(urlSession ?? defaultSession(sessionsPanel.data));
|
||||||
}
|
}
|
||||||
|
|
||||||
boot();
|
boot();
|
||||||
|
|||||||
+19
-1
@@ -32,11 +32,29 @@ export async function requestNotifications() {
|
|||||||
return Notification.requestPermission();
|
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) {
|
export function ring(session, detail) {
|
||||||
if (attentive()) return;
|
if (attentive()) return;
|
||||||
unread += 1;
|
unread += 1;
|
||||||
render();
|
render();
|
||||||
if (notificationsState() === 'granted') {
|
if (notificationsState() === 'granted' && !isMuted()) {
|
||||||
const note = new Notification(`Claude · ${session || 'devdesk'}`, {
|
const note = new Notification(`Claude · ${session || 'devdesk'}`, {
|
||||||
body: detail || 'Claude needs your attention',
|
body: detail || 'Claude needs your attention',
|
||||||
tag: `devdesk-${session}`,
|
tag: `devdesk-${session}`,
|
||||||
|
|||||||
+16
-11
@@ -21,21 +21,21 @@ function link(href, text) {
|
|||||||
export function renderRepo(root, repo) {
|
export function renderRepo(root, repo) {
|
||||||
const field = (name) => root.querySelector(`[data-field="${name}"]`);
|
const field = (name) => root.querySelector(`[data-field="${name}"]`);
|
||||||
const set = (name, value) => field(name).replaceChildren(value);
|
const set = (name, value) => field(name).replaceChildren(value);
|
||||||
|
const grid = root.querySelector('.dd-repo-grid');
|
||||||
const note = root.querySelector('.dd-repo-note');
|
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) {
|
if (!repo || !repo.git) {
|
||||||
root.dataset.state = 'empty';
|
root.dataset.state = 'empty';
|
||||||
for (const f of ['repo', 'branch', 'dirty', 'prs']) set(f, '—');
|
grid.hidden = true;
|
||||||
const ci = field('ci');
|
note.textContent = repo ? `Not a git repository: ${repo.dir}` : 'Open a session to see its repository.';
|
||||||
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.';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
root.dataset.state = 'live';
|
root.dataset.state = 'live';
|
||||||
|
grid.hidden = false;
|
||||||
set('repo', repo.url ? link(repo.url, repo.repo) : (repo.remote || 'no remote'));
|
set('repo', repo.url ? link(repo.url, repo.repo) : (repo.remote || 'no remote'));
|
||||||
const sync = repo.upstream
|
const sync = repo.upstream
|
||||||
? [repo.ahead ? `↑${repo.ahead}` : '', repo.behind ? `↓${repo.behind}` : ''].filter(Boolean).join(' ') || 'in sync'
|
? [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 ci = field('ci');
|
||||||
const [label, cls] = repo.ci?.total ? (CI[repo.ci.state] || [repo.ci.state, 'lt-status-pending'])
|
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.className = `lt-status ${cls}`;
|
||||||
ci.replaceChildren(repo.ci?.url ? link(repo.ci.url, label) : label);
|
ci.replaceChildren(repo.ci?.url ? link(repo.ci.url, label) : label);
|
||||||
|
|
||||||
set('prs', repo.url && repo.prs != null
|
set('prs', repo.url && repo.prs != null ? link(`${repo.url}/pulls`, `${repo.prs} open`) : '—');
|
||||||
? link(`${repo.url}/pulls`, `${repo.prs} open`)
|
|
||||||
: '—');
|
|
||||||
note.textContent = repo.repo ? '' : 'Not a Gitea repository; CI status unavailable.';
|
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.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-14
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import { fetchSessionList, sessionAction } from './connection.js';
|
import { fetchSessionList, sessionAction } from './connection.js';
|
||||||
import { slugify } from './protocol.js';
|
import { slugify } from './protocol.js';
|
||||||
|
import { share } from './shared.js';
|
||||||
|
|
||||||
const RECENTS_KEY = 'devdesk.recents';
|
const RECENTS_KEY = 'devdesk.recents';
|
||||||
const MAX_RECENTS = 12;
|
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) {
|
export function rememberSession(name) {
|
||||||
if (name) writeRecents([{ name, at: Date.now() }, ...readRecents().filter((r) => r.name !== 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 {
|
export class SessionsPanel {
|
||||||
// hooks: onOpen(name), onPicker(), onEnded(name), onRenamed(from, to), onData(data)
|
// hooks: onOpen(name), onEnding(name), onEnded(name), onEndAborted(), onRenamed(from, to), onData(data)
|
||||||
constructor({ list, form, nameInput, select, refreshButton, pickerButton, status, hooks }) {
|
constructor({ list, form, nameInput, select, refreshButton, newToggle, status, hooks }) {
|
||||||
Object.assign(this, { list, nameInput, select, status, hooks });
|
Object.assign(this, { list, form, nameInput, select, newToggle, status, hooks });
|
||||||
this.current = null;
|
this.current = null;
|
||||||
this.data = null;
|
this.data = null;
|
||||||
this.busy = false;
|
this.pending = null;
|
||||||
refreshButton.addEventListener('click', () => this.refresh());
|
refreshButton.addEventListener('click', () => this.refresh());
|
||||||
pickerButton.addEventListener('click', () => hooks.onPicker());
|
newToggle.addEventListener('click', () => this.toggleForm());
|
||||||
form.addEventListener('submit', (event) => {
|
form.addEventListener('submit', (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.createFromForm();
|
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) {
|
setCurrent(name) {
|
||||||
this.current = name;
|
this.current = name;
|
||||||
if (this.data) this.render();
|
if (this.data) this.render();
|
||||||
@@ -96,19 +108,31 @@ export class SessionsPanel {
|
|||||||
this.status.classList.toggle('is-error', isError);
|
this.status.classList.toggle('is-error', isError);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refresh() {
|
// Concurrent callers share the request in flight (boot awaits the first list).
|
||||||
if (this.busy) return;
|
refresh() {
|
||||||
this.busy = true;
|
this.pending ??= this.load().finally(() => { this.pending = null; });
|
||||||
|
return this.pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
let fetched = false;
|
||||||
try {
|
try {
|
||||||
this.data = await fetchSessionList();
|
this.data = await fetchSessionList();
|
||||||
|
fetched = true;
|
||||||
|
share('sessions', this.data);
|
||||||
if (!this.status.classList.contains('is-error')) this.setStatus('');
|
if (!this.status.classList.contains('is-error')) this.setStatus('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setStatus(`list unavailable: ${err.message}`, true);
|
this.setStatus(`list unavailable: ${err.message}`, true);
|
||||||
} finally {
|
|
||||||
this.busy = false;
|
|
||||||
}
|
}
|
||||||
this.render();
|
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) {
|
async act(label, action, ...args) {
|
||||||
@@ -135,6 +159,7 @@ export class SessionsPanel {
|
|||||||
if (project && !(await this.act('creating', 'new', name, project))) return;
|
if (project && !(await this.act('creating', 'new', name, project))) return;
|
||||||
this.nameInput.value = '';
|
this.nameInput.value = '';
|
||||||
this.select.value = '';
|
this.select.value = '';
|
||||||
|
this.toggleForm(false);
|
||||||
this.hooks.onOpen(name);
|
this.hooks.onOpen(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,18 +198,26 @@ export class SessionsPanel {
|
|||||||
const status = sessionStatus(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 ? `ctx ${Math.floor(s.context.used_pct)}%` : '';
|
||||||
const since = s.status?.since ? ago(s.status.since) : ago(s.activity);
|
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
|
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(' · ')
|
.filter(Boolean).join(' · ')
|
||||||
: 'dormant · resumes Claude';
|
: '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 = '') =>
|
const iconButton = (label, glyph, onclick, extra = '') =>
|
||||||
el('button', { class: `dd-icon-btn ${extra}`, type: 'button', title: label, 'aria-label': `${label} ${s.name}`, onclick }, glyph);
|
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' : ''}` },
|
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('button', { class: 'dd-session-open', type: 'button', title, onclick: () => this.hooks.onOpen(s.name) },
|
||||||
el('span', { class: `lt-dot ${status.dot}` }),
|
el('span', { class: `lt-dot ${status.dot}` }),
|
||||||
el('span', { class: 'dd-session-name' }, s.name),
|
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' },
|
el('span', { class: 'dd-session-actions' },
|
||||||
iconButton('Rename', '✎', () => this.rename(s.name)),
|
iconButton('Rename', '✎', () => this.rename(s.name)),
|
||||||
el('a', {
|
el('a', {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+3
-4
@@ -1,7 +1,6 @@
|
|||||||
// Sidebar widgets. In v1 only Sessions is live; Repo/CI and Claude Context get
|
// "Ask Claude": any [data-ask-claude] button (Ops Alerts, Repo/CI) types its
|
||||||
// data from devdesk-api in v1.1, Ops Alerts in v2. The "Ask Claude" pattern is
|
// prompt into the current session without pressing Enter, so you can review it
|
||||||
// already wired: any [data-ask-claude] button types its prompt into the current
|
// before sending.
|
||||||
// session without pressing Enter, so you can review it before sending.
|
|
||||||
|
|
||||||
export function bindAskClaude(root, send) {
|
export function bindAskClaude(root, send) {
|
||||||
root.addEventListener('click', (event) => {
|
root.addEventListener('click', (event) => {
|
||||||
|
|||||||
+180
-36
@@ -37,9 +37,12 @@ body.dd-app::after {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dd-header .lt-header-left {
|
.dd-header .lt-header-left {
|
||||||
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: var(--space-lg);
|
gap: var(--space-lg);
|
||||||
}
|
}
|
||||||
|
.dd-header .lt-header-right { flex: 0 0 auto; }
|
||||||
|
.dd-session { flex: 0 1 auto; }
|
||||||
|
|
||||||
.dd-session {
|
.dd-session {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -57,26 +60,115 @@ body.dd-app::after {
|
|||||||
max-width: 38vw;
|
max-width: 38vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dd-sso {
|
.dd-session-chip {
|
||||||
cursor: default;
|
display: inline-flex;
|
||||||
background: transparent;
|
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);
|
font-family: var(--font-mono);
|
||||||
}
|
font-size: 0.78rem;
|
||||||
.dd-sso[data-state="ok"] { color: var(--accent-green); background: var(--accent-green-dim); }
|
font-weight: 700;
|
||||||
.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);
|
|
||||||
cursor: pointer;
|
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); }
|
/* Connection status: a dot; the word only when it isn't "live". */
|
||||||
.dd-tools .lt-btn[data-state="denied"] { color: var(--accent-red); }
|
.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"] {
|
#sidebar-toggle[aria-expanded="true"] {
|
||||||
color: var(--accent-orange);
|
color: var(--accent-orange);
|
||||||
border-color: var(--accent-orange-border);
|
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 ---------------------------------------------------------------- */
|
/* ---- layout ---------------------------------------------------------------- */
|
||||||
.dd-main {
|
.dd-main {
|
||||||
@@ -134,14 +226,8 @@ body.dd-sidebar-open .dd-sidebar {
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dd-soon {
|
.dd-header-actions { margin-left: auto; display: inline-flex; gap: var(--space-sm); }
|
||||||
margin-left: auto;
|
|
||||||
color: var(--text-muted);
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dd-header-action {
|
.dd-header-action {
|
||||||
margin-left: auto;
|
|
||||||
background: none;
|
background: none;
|
||||||
border: 0;
|
border: 0;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -153,7 +239,7 @@ body.dd-sidebar-open .dd-sidebar {
|
|||||||
|
|
||||||
.dd-muted {
|
.dd-muted {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.68rem;
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dd-stub-note {
|
.dd-stub-note {
|
||||||
@@ -193,7 +279,7 @@ body.dd-sidebar-open .dd-sidebar {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.dd-session-open:hover { background: var(--accent-cyan-dim); }
|
.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 {
|
.dd-session-name {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
@@ -204,10 +290,22 @@ body.dd-sidebar-open .dd-sidebar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dd-session-meta {
|
.dd-session-meta {
|
||||||
font-size: 0.62rem;
|
font-size: 0.68rem;
|
||||||
color: var(--text-muted);
|
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-recent .dd-session-name { color: var(--text-secondary); font-weight: 400; }
|
||||||
|
|
||||||
.dd-session-item.is-dormant .dd-session-name { color: var(--text-secondary); }
|
.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-waiting .dd-session-meta { color: var(--accent-red); }
|
||||||
.dd-session-item.is-working .dd-session-meta { color: var(--accent-amber); }
|
.dd-session-item.is-working .dd-session-meta { color: var(--accent-amber); }
|
||||||
|
|
||||||
/* Header chips: current session's Claude state, and sessions needing you. */
|
/* base.css sets display on .lt-btn/.lt-chip, which beats the [hidden] attribute. */
|
||||||
.dd-state-chip { display: none; }
|
.dd-header [hidden] { display: none !important; }
|
||||||
.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); }
|
/* Header chip: other sessions waiting on you. */
|
||||||
.dd-state-chip[data-state="idle"] { display: inline-flex; color: var(--accent-green); background: var(--accent-green-dim); }
|
|
||||||
.dd-attention {
|
.dd-attention {
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
max-width: 22vw;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
display: none;
|
display: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@@ -276,6 +379,7 @@ body.dd-sidebar-open .dd-sidebar {
|
|||||||
padding: 4px 6px;
|
padding: 4px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dd-new-session[hidden] { display: none; }
|
||||||
.dd-new-session {
|
.dd-new-session {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
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-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-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; }
|
[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 ------------------------------------------------------ */
|
/* ---- file drop / upload ------------------------------------------------------ */
|
||||||
.dd-drop-overlay {
|
.dd-drop-overlay {
|
||||||
@@ -398,6 +504,33 @@ body.dd-sidebar-open .dd-sidebar {
|
|||||||
|
|
||||||
.dd-footer .lt-footer-key { margin-right: 4px; }
|
.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 ----------------------------------------------------------- */
|
/* ---- small screens ----------------------------------------------------------- */
|
||||||
@media (max-width: 1099px) {
|
@media (max-width: 1099px) {
|
||||||
.dd-sidebar {
|
.dd-sidebar {
|
||||||
@@ -405,7 +538,7 @@ body.dd-sidebar-open .dd-sidebar {
|
|||||||
top: var(--dd-gap);
|
top: var(--dd-gap);
|
||||||
right: var(--dd-gap);
|
right: var(--dd-gap);
|
||||||
bottom: 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;
|
z-index: 20;
|
||||||
background-color: var(--bg-secondary);
|
background-color: var(--bg-secondary);
|
||||||
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.6);
|
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-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) {
|
@media (max-width: 719px) {
|
||||||
:root { --dd-gap: 4px; }
|
:root { --dd-gap: 4px; }
|
||||||
.lt-brand-subtitle,
|
.lt-brand { display: none; }
|
||||||
#font-down,
|
.dd-session-chip { max-width: 46vw; }
|
||||||
#font-up,
|
/* Phones: the red Panels badge and the switcher carry "needs you" instead. */
|
||||||
#fullscreen-toggle,
|
.dd-attention.is-active { display: none; }
|
||||||
.dd-sso { display: none; }
|
.dd-header { padding: 0 var(--space-sm); gap: var(--space-sm); }
|
||||||
.dd-header .lt-header-left { 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-hints .lt-footer-hint:nth-child(n + 5),
|
||||||
.dd-footer .lt-footer-sep:nth-child(n + 4) { display: none; }
|
.dd-footer .lt-footer-sep:nth-child(n + 4) { display: none; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user