feat: session status at a glance, file & image drop, live Ops Alerts (0.4.0)
- devdesk-state-hook (Claude UserPromptSubmit/PostToolUse/Notification/Stop/ SessionStart/SessionEnd) records whether each session is working, waiting on you or idle; --list carries it. The sidebar shows it, the header shows the current session's state, and a pulsing "needs you" chip jumps to sessions waiting for input. Session refresh no longer depends on the sidebar being open. - `devdesk attach --upload SESSION FILE BYTES` receives a file as base64 terminal input (raw mode, exact length, size-checked, sanitised name) into /var/lib/devdesk/uploads/<session>/. The UI uploads dropped or pasted files and pastes the path into Claude, which attaches images itself. - `devdesk alerts` / `--alerts`: firing and pending Grafana alert rules with severity, instances, summary and a link. The panel's "Ask Claude" types an investigation prompt into the current session. 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 $(DESTDIR)$(LIBDIR)/
|
libexec/devdesk-deploy libexec/devdesk-state-hook $(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
|
||||||
|
|||||||
+83
-9
@@ -6,10 +6,12 @@
|
|||||||
# devdesk rename OLD NEW rename a session (running or dormant)
|
# devdesk rename OLD NEW rename a session (running or dormant)
|
||||||
# devdesk end NAME stop session NAME and forget it
|
# devdesk end NAME stop session NAME and forget it
|
||||||
# devdesk repo NAME git + Gitea CI status of the session's folder (JSON)
|
# devdesk repo NAME git + Gitea CI status of the session's folder (JSON)
|
||||||
|
# devdesk alerts firing/pending Grafana alerts (JSON)
|
||||||
# devdesk ls list sessions
|
# devdesk ls list sessions
|
||||||
# devdesk pick interactive picker
|
# devdesk pick interactive picker
|
||||||
#
|
#
|
||||||
# The web UI calls `devdesk attach --list | --repo NAME | --new NAME [PROJECT] | --rename OLD NEW | --end NAME`
|
# The web UI calls `devdesk attach --list | --repo NAME | --alerts | --upload SESSION FILE BYTES |
|
||||||
|
# --new NAME [PROJECT] | --rename OLD NEW | --end NAME`
|
||||||
# through ttyd; those print JSON.
|
# through ttyd; those print JSON.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -17,7 +19,7 @@ set -euo pipefail
|
|||||||
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
|
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
sed -n '2,11s/^# \{0,1\}//p' "$0"
|
sed -n '2,12s/^# \{0,1\}//p' "$0"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Tab-separated running sessions: name, dir, attached clients, last activity, windows.
|
# Tab-separated running sessions: name, dir, attached clients, last activity, windows.
|
||||||
@@ -41,11 +43,12 @@ projects_tsv() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
# {"<session>": {model, used_pct, ...}} from the status-line snapshots.
|
# {"<session>": {...}} merged from the per-session JSON files in DIR
|
||||||
context_json() {
|
# (status-line snapshots, hook states).
|
||||||
local f
|
per_session_json() {
|
||||||
if [ -d "$DEVDESK_CONTEXT_DIR" ]; then
|
local dir=$1 f
|
||||||
for f in "$DEVDESK_CONTEXT_DIR"/*.json; do
|
if [ -d "$dir" ]; then
|
||||||
|
for f in "$dir"/*.json; do
|
||||||
[ -f "$f" ] || continue
|
[ -f "$f" ] || continue
|
||||||
jq -cM --arg n "$(basename "$f" .json)" '{($n): .}' "$f" 2>/dev/null || true
|
jq -cM --arg n "$(basename "$f" .json)" '{($n): .}' "$f" 2>/dev/null || true
|
||||||
done
|
done
|
||||||
@@ -59,7 +62,8 @@ cmd_list() {
|
|||||||
--argjson reg "$reg" \
|
--argjson reg "$reg" \
|
||||||
--arg run "$(running_tsv)" \
|
--arg run "$(running_tsv)" \
|
||||||
--arg proj "$(projects_tsv)" \
|
--arg proj "$(projects_tsv)" \
|
||||||
--argjson ctx "$(context_json)" \
|
--argjson ctx "$(per_session_json "$DEVDESK_CONTEXT_DIR")" \
|
||||||
|
--argjson st "$(per_session_json "$DEVDESK_STATUS_DIR")" \
|
||||||
--arg dflt "$DEVDESK_DEFAULT_SESSION" \
|
--arg dflt "$DEVDESK_DEFAULT_SESSION" \
|
||||||
--arg root "$DEVDESK_PROJECTS_ROOT" '
|
--arg root "$DEVDESK_PROJECTS_ROOT" '
|
||||||
def rows($s): $s | split("\n") | map(select(length > 0) | split("\t"));
|
def rows($s): $s | split("\n") | map(select(length > 0) | split("\t"));
|
||||||
@@ -76,7 +80,7 @@ cmd_list() {
|
|||||||
| map({name: .key, dir: .value.dir, running: false, attached: 0,
|
| map({name: .key, dir: .value.dir, running: false, attached: 0,
|
||||||
activity: null, windows: 0,
|
activity: null, windows: 0,
|
||||||
claude_session_id: (.value.claude_session_id // null)}))
|
claude_session_id: (.value.claude_session_id // null)}))
|
||||||
) | map(. + {context: ($ctx[.name] // null)}),
|
) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null)}),
|
||||||
projects: ([{name: $dflt, dir: $root}]
|
projects: ([{name: $dflt, dir: $root}]
|
||||||
+ [rows($proj)[] | {name: .[0], dir: .[1]}
|
+ [rows($proj)[] | {name: .[0], dir: .[1]}
|
||||||
| select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}'
|
| select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}'
|
||||||
@@ -206,6 +210,66 @@ cmd_repo() {
|
|||||||
prs: $prs}'
|
prs: $prs}'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Firing and pending Grafana alert rules, most severe first.
|
||||||
|
cmd_alerts() {
|
||||||
|
if [ -z "$DEVDESK_GRAFANA_URL" ]; then
|
||||||
|
jq -cMn '{configured: false, alerts: []}'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local auth=() raw
|
||||||
|
if [ -r "$DEVDESK_GRAFANA_TOKEN_FILE" ]; then
|
||||||
|
auth=(-H "Authorization: Bearer $(tr -d '[:space:]' <"$DEVDESK_GRAFANA_TOKEN_FILE")")
|
||||||
|
fi
|
||||||
|
if ! raw=$(curl -fsS --max-time 6 "${auth[@]}" "$DEVDESK_GRAFANA_URL/api/prometheus/grafana/api/v1/rules" 2>&1); then
|
||||||
|
jq -cMn --arg e "${raw:-no response}" '{configured: true, error: $e, alerts: []}'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
jq -cM --arg pub "$DEVDESK_GRAFANA_PUBLIC_URL" '
|
||||||
|
def num: (tonumber? // .) | tostring;
|
||||||
|
{configured: true, checked: (now | floor), alerts: [
|
||||||
|
.data.groups[] | .name as $group | .file as $folder | .rules[]
|
||||||
|
| select(.state == "firing" or .state == "pending")
|
||||||
|
| . as $r
|
||||||
|
| ([.alerts[]? | select(.state == "Alerting" or .state == "Pending")]) as $active
|
||||||
|
| {name, state, severity: (.labels.severity // null), group: $group, folder: $folder,
|
||||||
|
since: (.activeAt // $active[0].activeAt // null),
|
||||||
|
summary: ((.annotations.summary // .annotations.description // "")
|
||||||
|
| gsub("\\{\\{ *\\$value *\\}\\}"; ($active[0].value // "?" | num))),
|
||||||
|
instances: [$active[] | .labels
|
||||||
|
| del(.__alert_rule_uid__, .__grafana_origin__, .alertname, .grafana_folder, .__name__, .severity)
|
||||||
|
| to_entries | map("\(.key)=\(.value)") | join(" ")
|
||||||
|
| select(. != "")],
|
||||||
|
url: "\($pub)/alerting/grafana/\(.uid)/view"}
|
||||||
|
] | sort_by(if .severity == "critical" then 0 elif .severity == "warning" then 1 else 2 end)}' <<<"$raw"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Receive a file for SESSION over the terminal: the browser waits for the ready
|
||||||
|
# line, then sends exactly ceil(BYTES/3)*4 bytes of base64 as input.
|
||||||
|
cmd_upload() {
|
||||||
|
[ $# -eq 3 ] || dd_die "usage: devdesk attach --upload SESSION FILENAME BYTES"
|
||||||
|
local session=$1 name size=$3 dir dest b64len actual
|
||||||
|
dd_valid_name "$session" || dd_die "invalid session name: $session"
|
||||||
|
{ [[ $size =~ ^[0-9]+$ ]] && [ "$size" -gt 0 ] && [ "$size" -le "$DEVDESK_UPLOAD_MAX" ]; } ||
|
||||||
|
dd_die "file is empty or larger than $((DEVDESK_UPLOAD_MAX / 1048576)) MB"
|
||||||
|
name=$(dd_safe_filename "$2")
|
||||||
|
dir="$DEVDESK_STATE_DIR/uploads/$session"
|
||||||
|
mkdir -p "$dir"
|
||||||
|
find "$DEVDESK_STATE_DIR/uploads" -type f -mtime +7 -delete 2>/dev/null || true
|
||||||
|
dest="$dir/$(date +%Y%m%d-%H%M%S)-$name"
|
||||||
|
# shellcheck disable=SC2017 # integer ceil(size/3)*4: the base64 length, on purpose
|
||||||
|
b64len=$(((size + 2) / 3 * 4))
|
||||||
|
stty raw -echo 2>/dev/null || true
|
||||||
|
printf 'DEVDESK-UPLOAD-READY\r\n'
|
||||||
|
head -c "$b64len" | base64 -d >"$dest" 2>/dev/null || true
|
||||||
|
stty sane 2>/dev/null || true
|
||||||
|
actual=$(stat -c %s "$dest" 2>/dev/null || echo 0)
|
||||||
|
if [ "$actual" != "$size" ]; then
|
||||||
|
rm -f "$dest"
|
||||||
|
dd_die "upload incomplete ($actual of $size bytes)"
|
||||||
|
fi
|
||||||
|
jq -cMn --arg p "$dest" '{ok: true, path: $p}'
|
||||||
|
}
|
||||||
|
|
||||||
ok_json() {
|
ok_json() {
|
||||||
printf '{"ok":true}\n'
|
printf '{"ok":true}\n'
|
||||||
}
|
}
|
||||||
@@ -225,6 +289,15 @@ cmd_attach() {
|
|||||||
cmd_repo "$@"
|
cmd_repo "$@"
|
||||||
return
|
return
|
||||||
;;
|
;;
|
||||||
|
--alerts)
|
||||||
|
cmd_alerts
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
--upload)
|
||||||
|
shift
|
||||||
|
cmd_upload "$@"
|
||||||
|
return
|
||||||
|
;;
|
||||||
--new | --rename | --end)
|
--new | --rename | --end)
|
||||||
local action=${1#--}
|
local action=${1#--}
|
||||||
shift
|
shift
|
||||||
@@ -263,6 +336,7 @@ attach | a) cmd_attach "$@" ;;
|
|||||||
new) cmd_new "$@" ;;
|
new) cmd_new "$@" ;;
|
||||||
rename | mv) cmd_rename "$@" ;;
|
rename | mv) cmd_rename "$@" ;;
|
||||||
repo) cmd_repo "$@" ;;
|
repo) cmd_repo "$@" ;;
|
||||||
|
alerts) cmd_alerts ;;
|
||||||
ls | list) cmd_ls ;;
|
ls | list) cmd_ls ;;
|
||||||
--list) cmd_list ;;
|
--list) cmd_list ;;
|
||||||
end) cmd_end "$@" ;;
|
end) cmd_end "$@" ;;
|
||||||
|
|||||||
Vendored
+16
@@ -1,3 +1,19 @@
|
|||||||
|
devdesk (0.4.0) bookworm; urgency=medium
|
||||||
|
|
||||||
|
* Session status at a glance: Claude hooks record whether each session is
|
||||||
|
working, waiting on you (with Claude's message) or idle. The sidebar shows
|
||||||
|
it with context %, the header shows the current session's state, and a
|
||||||
|
pulsing "needs you" chip jumps to sessions waiting for input.
|
||||||
|
* 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, which
|
||||||
|
attaches images itself.
|
||||||
|
* Ops Alerts panel is live: firing/pending Grafana alert rules with
|
||||||
|
severity, instances, summary and a link, plus an "Ask Claude" button that
|
||||||
|
types an investigation prompt into the current session.
|
||||||
|
|
||||||
|
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 19:24:15 -0400
|
||||||
|
|
||||||
devdesk (0.3.1) bookworm; urgency=medium
|
devdesk (0.3.1) bookworm; urgency=medium
|
||||||
|
|
||||||
* Auto-deploy: tagged releases trigger CT 126's webhook listener (house HMAC
|
* Auto-deploy: tagged releases trigger CT 126's webhook listener (house HMAC
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ DEVDESK_DEFAULT_DIR=
|
|||||||
DEVDESK_GITEA_URL=https://code.lotusguild.org
|
DEVDESK_GITEA_URL=https://code.lotusguild.org
|
||||||
DEVDESK_GITEA_ALIASES=10.10.10.63
|
DEVDESK_GITEA_ALIASES=10.10.10.63
|
||||||
|
|
||||||
|
# Grafana for the Ops Alerts panel (firing/pending alert rules). A Viewer
|
||||||
|
# service-account token goes in DEVDESK_GRAFANA_TOKEN_FILE (mode 600).
|
||||||
|
DEVDESK_GRAFANA_URL=http://10.10.10.49:3000
|
||||||
|
DEVDESK_GRAFANA_PUBLIC_URL=https://dashboard.lotusguild.org
|
||||||
|
DEVDESK_GRAFANA_TOKEN_FILE=/etc/devdesk/grafana-token
|
||||||
|
|
||||||
# Claude Code binary and extra arguments for every session.
|
# Claude Code binary and extra arguments for every session.
|
||||||
CLAUDE_BIN=/root/.local/bin/claude
|
CLAUDE_BIN=/root/.local/bin/claude
|
||||||
CLAUDE_ARGS=
|
CLAUDE_ARGS=
|
||||||
|
|||||||
+21
-4
@@ -30,9 +30,14 @@ dd_load_env
|
|||||||
: "${DEVDESK_GITEA_URL:=https://code.lotusguild.org}"
|
: "${DEVDESK_GITEA_URL:=https://code.lotusguild.org}"
|
||||||
: "${DEVDESK_GITEA_ALIASES:=10.10.10.63}"
|
: "${DEVDESK_GITEA_ALIASES:=10.10.10.63}"
|
||||||
: "${DEVDESK_GITEA_TOKEN_FILE:=/etc/devdesk/gitea-token}"
|
: "${DEVDESK_GITEA_TOKEN_FILE:=/etc/devdesk/gitea-token}"
|
||||||
|
: "${DEVDESK_GRAFANA_URL:=}"
|
||||||
|
: "${DEVDESK_GRAFANA_PUBLIC_URL:=$DEVDESK_GRAFANA_URL}"
|
||||||
|
: "${DEVDESK_GRAFANA_TOKEN_FILE:=/etc/devdesk/grafana-token}"
|
||||||
|
: "${DEVDESK_UPLOAD_MAX:=26214400}"
|
||||||
|
|
||||||
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"
|
||||||
|
DEVDESK_STATUS_DIR="$DEVDESK_STATE_DIR/state"
|
||||||
DEVDESK_NAME_RE='^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$'
|
DEVDESK_NAME_RE='^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$'
|
||||||
|
|
||||||
dd_die() {
|
dd_die() {
|
||||||
@@ -113,15 +118,18 @@ dd_registry_set_claude() {
|
|||||||
|
|
||||||
dd_registry_remove() {
|
dd_registry_remove() {
|
||||||
dd_registry_update 'del(.sessions[$n])' --arg n "$1"
|
dd_registry_update 'del(.sessions[$n])' --arg n "$1"
|
||||||
rm -f "$DEVDESK_CONTEXT_DIR/$1.json"
|
rm -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_STATUS_DIR/$1.json"
|
||||||
}
|
}
|
||||||
|
|
||||||
dd_registry_rename() {
|
dd_registry_rename() {
|
||||||
dd_registry_update 'if .sessions[$old] then .sessions[$new] = .sessions[$old] | del(.sessions[$old]) else . end' \
|
dd_registry_update 'if .sessions[$old] then .sessions[$new] = .sessions[$old] | del(.sessions[$old]) else . end' \
|
||||||
--arg old "$1" --arg new "$2"
|
--arg old "$1" --arg new "$2"
|
||||||
if [ -f "$DEVDESK_CONTEXT_DIR/$1.json" ]; then
|
local d
|
||||||
mv -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_CONTEXT_DIR/$2.json"
|
for d in "$DEVDESK_CONTEXT_DIR" "$DEVDESK_STATUS_DIR"; do
|
||||||
fi
|
if [ -f "$d/$1.json" ]; then
|
||||||
|
mv -f "$d/$1.json" "$d/$2.json"
|
||||||
|
fi
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
# Project name (directory or its slug) -> absolute directory inside the projects root.
|
# Project name (directory or its slug) -> absolute directory inside the projects root.
|
||||||
@@ -147,6 +155,15 @@ dd_project_dir() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Upload filename -> safe basename ([A-Za-z0-9._-], no leading dots, max 100 chars).
|
||||||
|
dd_safe_filename() {
|
||||||
|
local n=${1##*/}
|
||||||
|
n=${n//[^A-Za-z0-9._-]/_}
|
||||||
|
while [[ $n == .* ]]; do n=${n:1}; done
|
||||||
|
n=${n:0:100}
|
||||||
|
printf '%s' "${n:-upload}"
|
||||||
|
}
|
||||||
|
|
||||||
# Session name -> working directory. Sessions don't need a project: the
|
# Session name -> working directory. Sessions don't need a project: the
|
||||||
# registry wins, then a project of the same name, then DEVDESK_DEFAULT_DIR.
|
# registry wins, then a project of the same name, then DEVDESK_DEFAULT_DIR.
|
||||||
dd_resolve_dir() {
|
dd_resolve_dir() {
|
||||||
|
|||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Claude Code hook (wired in via claude-settings.json) that tracks what Claude is
|
||||||
|
# doing in each DevDesk session, for the sidebar's at-a-glance status:
|
||||||
|
# UserPromptSubmit, PostToolUse -> working
|
||||||
|
# Notification -> waiting (Claude needs input or a permission)
|
||||||
|
# Stop, SessionStart -> idle
|
||||||
|
# SessionEnd -> exited
|
||||||
|
# Fires on every tool call, so it stays tiny and only writes when the state changes.
|
||||||
|
|
||||||
|
input=$(cat)
|
||||||
|
|
||||||
|
outer=${TMUX:-}
|
||||||
|
[ "${outer%%,*}" = "${DEVDESK_SOCKET:-/run/devdesk/tmux.sock}" ] && [ -n "${TMUX_PANE:-}" ] || exit 0
|
||||||
|
session=$(tmux display-message -p -t "$TMUX_PANE" '#S' 2>/dev/null)
|
||||||
|
[[ $session =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]] || exit 0
|
||||||
|
|
||||||
|
IFS=$'\t' read -r event message < <(jq -rM '[.hook_event_name // "", (.message // "" | gsub("[\t\n]"; " "))] | @tsv' <<<"$input" 2>/dev/null)
|
||||||
|
case $event in
|
||||||
|
UserPromptSubmit | PostToolUse) state=working ;;
|
||||||
|
Notification) state=waiting ;;
|
||||||
|
Stop | SessionStart) state=idle ;;
|
||||||
|
SessionEnd) state=exited ;;
|
||||||
|
*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
dir="${DEVDESK_STATE_DIR:-/var/lib/devdesk}/state"
|
||||||
|
file="$dir/$session.json"
|
||||||
|
# Unchanged state (the common case during tool runs): nothing to write.
|
||||||
|
if [ "$state" != waiting ] && [ -f "$file" ] && grep -q "\"state\":\"$state\"" "$file"; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
mkdir -p "$dir" 2>/dev/null || exit 0
|
||||||
|
tmp=$(mktemp "$dir/.$session.XXXXXX" 2>/dev/null) || exit 0
|
||||||
|
if jq -cMn --arg s "$state" --arg m "$message" --arg e "$event" \
|
||||||
|
'{state: $s, since: (now | floor), event: $e, message: (if $m == "" then null else $m end)}' >"$tmp"; then
|
||||||
|
mv -f "$tmp" "$file"
|
||||||
|
else
|
||||||
|
rm -f "$tmp"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
@@ -8,6 +8,67 @@
|
|||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "/usr/lib/devdesk/devdesk-session-hook",
|
"command": "/usr/lib/devdesk/devdesk-session-hook",
|
||||||
"timeout": 10
|
"timeout": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "/usr/lib/devdesk/devdesk-state-hook",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "/usr/lib/devdesk/devdesk-state-hook",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Notification": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "/usr/lib/devdesk/devdesk-state-hook",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "/usr/lib/devdesk/devdesk-state-hook",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionEnd": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "/usr/lib/devdesk/devdesk-state-hook",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "/usr/lib/devdesk/devdesk-state-hook",
|
||||||
|
"timeout": 5
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,6 +246,55 @@ claude_args() {
|
|||||||
[ "$output" = "$DEVDESK_PROJECTS_ROOT/alpha/ops" ]
|
[ "$output" = "$DEVDESK_PROJECTS_ROOT/alpha/ops" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "state hook tracks working, waiting and idle per session" {
|
||||||
|
start_server
|
||||||
|
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"; }
|
||||||
|
state() { jq -r '.sessions[] | select(.name=="alpha") | .status.state // "none"' <<<"$(devdesk attach --list)"; }
|
||||||
|
echo '{"hook_event_name":"UserPromptSubmit","prompt":"hi"}' | hook
|
||||||
|
[ "$(state)" = working ]
|
||||||
|
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Bash"}' | hook
|
||||||
|
[ "$(state)" = waiting ]
|
||||||
|
[ "$(jq -r '.message' "$DEVDESK_STATE_DIR/state/alpha.json")" = "Claude needs your permission to use Bash" ]
|
||||||
|
echo '{"hook_event_name":"PostToolUse","tool_name":"Bash"}' | hook
|
||||||
|
[ "$(state)" = working ]
|
||||||
|
echo '{"hook_event_name":"Stop"}' | hook
|
||||||
|
[ "$(state)" = idle ]
|
||||||
|
echo '{"hook_event_name":"PreCompact"}' | hook
|
||||||
|
[ "$(state)" = idle ]
|
||||||
|
devdesk rename alpha beta
|
||||||
|
[ -f "$DEVDESK_STATE_DIR/state/beta.json" ] && [ ! -e "$DEVDESK_STATE_DIR/state/alpha.json" ]
|
||||||
|
devdesk end beta
|
||||||
|
[ ! -e "$DEVDESK_STATE_DIR/state/beta.json" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "alerts reports unconfigured and unreachable Grafana cleanly" {
|
||||||
|
run env DEVDESK_GRAFANA_URL= devdesk alerts
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$(jq -r '.configured' <<<"$output")" = false ]
|
||||||
|
run env DEVDESK_GRAFANA_URL=http://127.0.0.1:9 devdesk attach --alerts
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$(jq -r '.configured' <<<"$output")" = true ]
|
||||||
|
[ "$(jq -r '.error | length > 0' <<<"$output")" = true ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "upload stores the exact bytes under a sanitised name" {
|
||||||
|
head -c 50001 /dev/urandom >"$TMP/in.bin"
|
||||||
|
run bash -c "base64 -w0 '$TMP/in.bin' | devdesk attach --upload alpha '../../etc/My Shot (1).png' 50001"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
path=$(tail -n1 <<<"$output" | jq -r .path)
|
||||||
|
[[ $path == "$DEVDESK_STATE_DIR/uploads/alpha/"*-My_Shot__1_.png ]]
|
||||||
|
cmp "$TMP/in.bin" "$path"
|
||||||
|
run bash -c "printf short | devdesk attach --upload alpha x.bin 100"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ $output == *"upload incomplete"* ]]
|
||||||
|
run devdesk attach --upload alpha x.bin 999999999999
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
run devdesk attach --upload ../x x.bin 10
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
@test "web actions run through attach and answer with JSON" {
|
@test "web actions run through attach and answer with JSON" {
|
||||||
start_server
|
start_server
|
||||||
run devdesk attach --new web1 alpha
|
run devdesk attach --new web1 alpha
|
||||||
|
|||||||
+8
-12
@@ -20,10 +20,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="dd-session">
|
<div class="dd-session">
|
||||||
<span class="lt-chip lt-chip-info" id="session-chip">—</span>
|
<span class="lt-chip lt-chip-info" id="session-chip">—</span>
|
||||||
|
<span class="lt-chip dd-state-chip" id="state-chip" data-state=""></span>
|
||||||
<span class="dd-pane-title" id="pane-title"></span>
|
<span class="dd-pane-title" id="pane-title"></span>
|
||||||
</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>
|
||||||
<span class="lt-ws-status" id="ws-status" data-state="connecting" role="status">
|
<span class="lt-ws-status" 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>
|
||||||
@@ -41,6 +43,8 @@
|
|||||||
<main class="dd-main">
|
<main class="dd-main">
|
||||||
<section class="lt-frame dd-terminal-frame" aria-label="Terminal">
|
<section class="lt-frame dd-terminal-frame" aria-label="Terminal">
|
||||||
<div id="terminal" class="dd-terminal"></div>
|
<div id="terminal" class="dd-terminal"></div>
|
||||||
|
<div id="drop-overlay" class="dd-drop-overlay" hidden><span>Drop to attach to <b id="drop-session">this session</b></span></div>
|
||||||
|
<div id="upload-status" class="dd-upload-status" role="status" hidden></div>
|
||||||
<div id="overlay" class="dd-overlay" data-tone="info" hidden>
|
<div id="overlay" class="dd-overlay" data-tone="info" hidden>
|
||||||
<div class="lt-frame dd-overlay-card" role="alertdialog" aria-labelledby="overlay-title" aria-describedby="overlay-text">
|
<div class="lt-frame dd-overlay-card" role="alertdialog" aria-labelledby="overlay-title" aria-describedby="overlay-text">
|
||||||
<div class="lt-section-header" id="overlay-title">Disconnected</div>
|
<div class="lt-section-header" id="overlay-title">Disconnected</div>
|
||||||
@@ -103,21 +107,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="lt-frame dd-widget" id="widget-alerts" data-state="stub">
|
<section class="lt-frame dd-widget" id="widget-alerts" data-state="empty">
|
||||||
<div class="lt-section-header">Ops Alerts <span class="lt-badge lt-badge-sm dd-soon">v2</span></div>
|
<div class="lt-section-header">Ops Alerts <span class="lt-badge lt-badge-sm dd-alert-count"></span></div>
|
||||||
<div class="lt-section-body">
|
<div class="lt-section-body">
|
||||||
<ul class="dd-alert-list">
|
<ul class="dd-alert-list"><li class="dd-empty">Loading…</li></ul>
|
||||||
<li class="dd-alert">
|
<p class="dd-muted dd-stub-note dd-alerts-note"></p>
|
||||||
<span class="lt-dot lt-dot-idle"></span>
|
|
||||||
<span class="dd-alert-text">No alert source connected</span>
|
|
||||||
<button type="button" class="lt-btn lt-btn-sm dd-ask" disabled
|
|
||||||
data-ask-claude="Investigate the firing alert: ">Ask Claude</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p class="dd-muted dd-stub-note">Firing Grafana alerts, each with an “Ask Claude” prompt.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Ops Alerts panel: firing/pending Grafana alert rules (`devdesk attach --alerts`),
|
||||||
|
// each with an "Ask Claude" button that types an investigation prompt into the
|
||||||
|
// current session (without pressing Enter).
|
||||||
|
|
||||||
|
import { el } from './sessions.js';
|
||||||
|
|
||||||
|
const SEVERITY = {
|
||||||
|
critical: 'lt-chip-critical',
|
||||||
|
warning: 'lt-chip-warn',
|
||||||
|
};
|
||||||
|
|
||||||
|
function age(iso) {
|
||||||
|
const t = Date.parse(iso || '');
|
||||||
|
if (Number.isNaN(t)) return '';
|
||||||
|
const s = Math.max(0, (Date.now() - t) / 1000);
|
||||||
|
if (s < 3600) return `${Math.max(1, Math.floor(s / 60))}m`;
|
||||||
|
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
||||||
|
return `${Math.floor(s / 86400)}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function investigatePrompt(alert) {
|
||||||
|
const where = alert.instances?.length ? ` on ${alert.instances.join('; ')}` : '';
|
||||||
|
return `Investigate the ${alert.state} Grafana alert "${alert.name}"`
|
||||||
|
+ ` (${alert.severity || 'no severity'}, ${alert.folder} / ${alert.group}${where}, since ${alert.since}).`
|
||||||
|
+ ` Summary: ${alert.summary || 'n/a'}. Use the grafana MCP server to look at the rule and the related`
|
||||||
|
+ ' metrics, find the cause, and tell me what you would change before changing anything.';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderAlerts(root, data) {
|
||||||
|
const list = root.querySelector('.dd-alert-list');
|
||||||
|
const note = root.querySelector('.dd-alerts-note');
|
||||||
|
const count = root.querySelector('.dd-alert-count');
|
||||||
|
const alerts = data?.alerts ?? [];
|
||||||
|
count.textContent = alerts.length ? String(alerts.length) : '';
|
||||||
|
count.className = `lt-badge lt-badge-sm dd-alert-count${alerts.some((a) => a.severity === 'critical') ? ' lt-badge-p1' : alerts.length ? ' lt-badge-p2' : ''}`;
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
root.dataset.state = 'empty';
|
||||||
|
list.replaceChildren(el('li', { class: 'dd-empty' }, 'Loading…'));
|
||||||
|
note.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.configured) {
|
||||||
|
root.dataset.state = 'empty';
|
||||||
|
list.replaceChildren(el('li', { class: 'dd-empty' }, 'Grafana is not configured.'));
|
||||||
|
note.textContent = 'Set DEVDESK_GRAFANA_URL and a token file in /etc/devdesk/env.local.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.dataset.state = 'live';
|
||||||
|
note.textContent = data.error ? `Grafana unreachable: ${data.error}` : '';
|
||||||
|
if (!alerts.length) {
|
||||||
|
list.replaceChildren(el('li', { class: 'dd-all-clear' },
|
||||||
|
el('span', { class: 'lt-dot lt-dot-up' }), data.error ? 'No data' : 'All clear: nothing firing'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.replaceChildren(...alerts.map((a) => el('li', { class: `dd-alert is-${a.state}` },
|
||||||
|
el('div', { class: 'dd-alert-head' },
|
||||||
|
el('span', { class: `lt-chip ${SEVERITY[a.severity] || 'lt-chip-info'}` }, a.state === 'pending' ? 'pending' : (a.severity || 'alert')),
|
||||||
|
el('a', { class: 'dd-alert-name', href: a.url, target: '_blank', rel: 'noopener', title: `${a.folder} / ${a.group}` }, a.name),
|
||||||
|
el('span', { class: 'dd-muted dd-alert-age' }, age(a.since))),
|
||||||
|
a.instances?.length ? el('div', { class: 'dd-muted dd-alert-instances' }, a.instances.join(' · ')) : null,
|
||||||
|
a.summary ? el('div', { class: 'dd-alert-summary' }, a.summary) : null,
|
||||||
|
el('button', { type: 'button', class: 'lt-btn lt-btn-sm dd-ask', 'data-ask-claude': investigatePrompt(a) }, 'Ask Claude'))));
|
||||||
|
}
|
||||||
@@ -124,6 +124,73 @@ export async function fetchSessionList() {
|
|||||||
return parseListOutput(await runCommand([LIST_ARG]));
|
return parseListOutput(await runCommand([LIST_ARG]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Firing/pending Grafana alerts (`devdesk attach --alerts`).
|
||||||
|
export async function fetchAlerts() {
|
||||||
|
return parseListOutput(await runCommand(['--alerts']));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBase64(bytes) {
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||||
|
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
// Upload a file into the session's upload folder through ttyd: wait for the
|
||||||
|
// server's ready line, stream the file as base64 input, read back {ok, path}.
|
||||||
|
export async function uploadFile(session, file, onProgress) {
|
||||||
|
const token = await fetchToken();
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const b64 = toBase64(bytes);
|
||||||
|
const name = (file.name || 'pasted-image.png').slice(-100);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = new WebSocket(wsUrl(window.location, ['--upload', session, name, String(bytes.length)]), ['tty']);
|
||||||
|
socket.binaryType = 'arraybuffer';
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let output = '';
|
||||||
|
let sending = false;
|
||||||
|
const timer = setTimeout(() => socket.close(), 30000 + bytes.length / 50);
|
||||||
|
const send = async () => {
|
||||||
|
const chunk = 48 * 1024;
|
||||||
|
for (let i = 0; i < b64.length; i += chunk) {
|
||||||
|
while (socket.bufferedAmount > 1 << 20) await sleep(20);
|
||||||
|
if (socket.readyState !== WebSocket.OPEN) return;
|
||||||
|
socket.send(encodeInput(b64.slice(i, i + chunk)));
|
||||||
|
onProgress?.(Math.min(1, (i + chunk) / b64.length));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
const { type, payload } = decodeServer(event.data);
|
||||||
|
if (type !== Server.OUTPUT) return;
|
||||||
|
output += decoder.decode(payload, { stream: true });
|
||||||
|
if (!sending && output.includes('DEVDESK-UPLOAD-READY')) {
|
||||||
|
sending = true;
|
||||||
|
send();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.onerror = () => {};
|
||||||
|
socket.onclose = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
const text = stripAnsi(output.replace(/^[\s\S]*DEVDESK-UPLOAD-READY/, ''));
|
||||||
|
try {
|
||||||
|
const result = parseListOutput(text);
|
||||||
|
if (result.ok && result.path) {
|
||||||
|
resolve(result.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to the error message
|
||||||
|
}
|
||||||
|
const message = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).pop();
|
||||||
|
reject(new Error((message || 'upload failed').replace(/^devdesk:\s*/, '')));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Git + Gitea CI status for a session's folder (`devdesk attach --repo NAME`).
|
// Git + Gitea CI status for a session's folder (`devdesk attach --repo NAME`).
|
||||||
export async function fetchRepo(session) {
|
export async function fetchRepo(session) {
|
||||||
return parseListOutput(await runCommand(['--repo', session]));
|
return parseListOutput(await runCommand(['--repo', session]));
|
||||||
|
|||||||
+119
-3
@@ -1,8 +1,11 @@
|
|||||||
/* global __DEVDESK_VERSION__ */
|
/* global __DEVDESK_VERSION__ */
|
||||||
import { createTerminal, loadFonts } from './term.js';
|
import { createTerminal, loadFonts } from './term.js';
|
||||||
import { TtydConnection, fetchRepo, fetchToken, AuthExpiredError, UnauthorizedError } from './connection.js';
|
import {
|
||||||
|
TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError,
|
||||||
|
} from './connection.js';
|
||||||
import { parseTitle, sessionFromSearch } from './protocol.js';
|
import { parseTitle, sessionFromSearch } from './protocol.js';
|
||||||
import { SessionsPanel, rememberSession } from './sessions.js';
|
import { SessionsPanel, rememberSession, sessionStatus } from './sessions.js';
|
||||||
|
import { renderAlerts } from './alerts.js';
|
||||||
import { renderContext } from './context.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';
|
||||||
@@ -34,6 +37,8 @@ const ui = {
|
|||||||
wsLabel: $('ws-label'),
|
wsLabel: $('ws-label'),
|
||||||
sso: $('sso-badge'),
|
sso: $('sso-badge'),
|
||||||
sessionChip: $('session-chip'),
|
sessionChip: $('session-chip'),
|
||||||
|
stateChip: $('state-chip'),
|
||||||
|
attention: $('attention-chip'),
|
||||||
paneTitle: $('pane-title'),
|
paneTitle: $('pane-title'),
|
||||||
overlay: $('overlay'),
|
overlay: $('overlay'),
|
||||||
overlayTitle: $('overlay-title'),
|
overlayTitle: $('overlay-title'),
|
||||||
@@ -95,6 +100,107 @@ function showContext() {
|
|||||||
const data = sessionsPanel?.data;
|
const data = sessionsPanel?.data;
|
||||||
const current = data?.sessions?.find((s) => s.name === state.session);
|
const current = data?.sessions?.find((s) => s.name === state.session);
|
||||||
renderContext($('widget-context'), state.session, current?.context ?? null);
|
renderContext($('widget-context'), state.session, current?.context ?? null);
|
||||||
|
showSessionState(data, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header: what Claude is doing here, and which other sessions are waiting on you.
|
||||||
|
function showSessionState(data, current) {
|
||||||
|
const status = current ? sessionStatus(current) : null;
|
||||||
|
const key = status && ['working', 'waiting', 'idle'].includes(status.key) ? status.key : '';
|
||||||
|
ui.stateChip.dataset.state = key;
|
||||||
|
ui.stateChip.textContent = key ? status.label : '';
|
||||||
|
ui.stateChip.title = current?.status?.message || '';
|
||||||
|
const waiting = (data?.sessions ?? []).filter((s) => s.name !== state.session && sessionStatus(s).key === 'waiting');
|
||||||
|
ui.attention.classList.toggle('is-active', waiting.length > 0);
|
||||||
|
ui.attention.textContent = waiting.length === 1 ? `${waiting[0].name} needs you` : `${waiting.length} need you`;
|
||||||
|
ui.attention.title = waiting.map((s) => `${s.name}: ${s.status?.message || 'waiting'}`).join('\n');
|
||||||
|
ui.attention.dataset.target = waiting[0]?.name || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let alertsBusy = false;
|
||||||
|
async function refreshAlerts() {
|
||||||
|
if (alertsBusy) return;
|
||||||
|
alertsBusy = true;
|
||||||
|
try {
|
||||||
|
renderAlerts($('widget-alerts'), await fetchAlerts());
|
||||||
|
} catch {
|
||||||
|
// Keep the last rendering; the next refresh retries.
|
||||||
|
} finally {
|
||||||
|
alertsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- file drop / paste
|
||||||
|
|
||||||
|
const uploadUi = $('upload-status');
|
||||||
|
let uploadHideTimer = 0;
|
||||||
|
function uploadMessage(text, isError = false, sticky = false) {
|
||||||
|
clearTimeout(uploadHideTimer);
|
||||||
|
uploadUi.hidden = false;
|
||||||
|
uploadUi.textContent = text;
|
||||||
|
uploadUi.classList.toggle('is-error', isError);
|
||||||
|
if (!sticky) uploadHideTimer = setTimeout(() => { uploadUi.hidden = true; }, isError ? 8000 : 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachFiles(files) {
|
||||||
|
if (!files.length) return;
|
||||||
|
if (!state.session || !state.conn?.isOpen) {
|
||||||
|
uploadMessage('Open a session first: files are attached to the current session.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = state.session;
|
||||||
|
const paths = [];
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const path = await uploadFile(session, file, (p) =>
|
||||||
|
uploadMessage(`Uploading ${file.name || 'image'}… ${Math.round(p * 100)}%`, false, true));
|
||||||
|
paths.push(path);
|
||||||
|
} catch (error) {
|
||||||
|
uploadMessage(`${file.name || 'file'}: ${error.message}`, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (session !== state.session) return;
|
||||||
|
// A pasted image path makes Claude attach the image itself.
|
||||||
|
term.paste(`${paths.join(' ')} `);
|
||||||
|
term.focus();
|
||||||
|
uploadMessage(paths.length === 1 ? `Attached ${paths[0]}` : `Attached ${paths.length} files`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireUploads() {
|
||||||
|
const frame = document.querySelector('.dd-terminal-frame');
|
||||||
|
const overlay = $('drop-overlay');
|
||||||
|
let depth = 0;
|
||||||
|
const hasFiles = (event) => [...(event.dataTransfer?.types ?? [])].includes('Files');
|
||||||
|
frame.addEventListener('dragenter', (event) => {
|
||||||
|
if (!hasFiles(event)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
depth += 1;
|
||||||
|
$('drop-session').textContent = state.session || 'a session';
|
||||||
|
overlay.hidden = false;
|
||||||
|
});
|
||||||
|
frame.addEventListener('dragover', (event) => {
|
||||||
|
if (hasFiles(event)) event.preventDefault();
|
||||||
|
});
|
||||||
|
frame.addEventListener('dragleave', () => {
|
||||||
|
depth = Math.max(0, depth - 1);
|
||||||
|
if (!depth) overlay.hidden = true;
|
||||||
|
});
|
||||||
|
frame.addEventListener('drop', (event) => {
|
||||||
|
if (!hasFiles(event)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
depth = 0;
|
||||||
|
overlay.hidden = true;
|
||||||
|
attachFiles([...event.dataTransfer.files]);
|
||||||
|
});
|
||||||
|
// Pasting a screenshot / copied file: upload it instead of pasting text.
|
||||||
|
document.addEventListener('paste', (event) => {
|
||||||
|
const files = [...(event.clipboardData?.files ?? [])];
|
||||||
|
if (!files.length) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
attachFiles(files);
|
||||||
|
}, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Repo / CI panel for the current session; notifies when HEAD's CI finishes.
|
// Repo / CI panel for the current session; notifies when HEAD's CI finishes.
|
||||||
@@ -361,9 +467,16 @@ function wireChrome() {
|
|||||||
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.
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) sessionsPanel.refresh();
|
if (!document.hidden) sessionsPanel.refresh();
|
||||||
}, 15 * 1000);
|
}, 15 * 1000);
|
||||||
|
setInterval(() => {
|
||||||
|
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshAlerts();
|
||||||
|
}, 30 * 1000);
|
||||||
|
ui.attention.addEventListener('click', () => {
|
||||||
|
if (ui.attention.dataset.target) connect(ui.attention.dataset.target);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- boot
|
// ---------------------------------------------------------------- boot
|
||||||
@@ -435,6 +548,9 @@ async function boot() {
|
|||||||
term.focus();
|
term.focus();
|
||||||
});
|
});
|
||||||
wireChrome();
|
wireChrome();
|
||||||
|
wireUploads();
|
||||||
|
sessionsPanel.refresh();
|
||||||
|
refreshAlerts();
|
||||||
connect(state.session);
|
connect(state.session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-5
@@ -57,6 +57,20 @@ export function el(tag, attrs = {}, ...children) {
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What Claude is doing in a session, from the devdesk-state-hook snapshot.
|
||||||
|
// Returns { key, label, dot } for display; key is one of
|
||||||
|
// waiting | working | idle | exited | running | dormant.
|
||||||
|
export function sessionStatus(s) {
|
||||||
|
if (!s?.running) return { key: 'dormant', label: 'dormant', dot: 'lt-dot-idle' };
|
||||||
|
switch (s.status?.state) {
|
||||||
|
case 'waiting': return { key: 'waiting', label: 'needs you', dot: 'lt-dot-down' };
|
||||||
|
case 'working': return { key: 'working', label: 'working', dot: 'lt-dot-warn' };
|
||||||
|
case 'idle': return { key: 'idle', label: 'idle', dot: 'lt-dot-up' };
|
||||||
|
case 'exited': return { key: 'exited', label: 'Claude exited', dot: 'lt-dot-idle' };
|
||||||
|
default: return { key: 'running', label: 'running', dot: 'lt-dot-up' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class SessionsPanel {
|
export class SessionsPanel {
|
||||||
// hooks: onOpen(name), onPicker(), onEnded(name), onRenamed(from, to), onData(data)
|
// hooks: onOpen(name), onPicker(), onEnded(name), onRenamed(from, to), onData(data)
|
||||||
constructor({ list, form, nameInput, select, refreshButton, pickerButton, status, hooks }) {
|
constructor({ list, form, nameInput, select, refreshButton, pickerButton, status, hooks }) {
|
||||||
@@ -156,15 +170,19 @@ export class SessionsPanel {
|
|||||||
|
|
||||||
renderSession(s) {
|
renderSession(s) {
|
||||||
const isCurrent = s.name === this.current;
|
const isCurrent = s.name === this.current;
|
||||||
const ctx = s.context?.used_pct != null ? ` · ctx ${Math.floor(s.context.used_pct)}%` : '';
|
const status = sessionStatus(s);
|
||||||
|
const ctx = s.context?.used_pct != null ? `ctx ${Math.floor(s.context.used_pct)}%` : '';
|
||||||
|
const since = s.status?.since ? ago(s.status.since) : ago(s.activity);
|
||||||
const meta = s.running
|
const meta = s.running
|
||||||
? `${s.attached} attached · ${ago(s.activity)}${ctx}`
|
? [`${status.label}${since && since !== 'now' ? ` ${since}` : ''}`, ctx, s.attached ? `${s.attached} open` : '']
|
||||||
|
.filter(Boolean).join(' · ')
|
||||||
: 'dormant · resumes Claude';
|
: 'dormant · resumes Claude';
|
||||||
|
const title = [s.dir, status.key === 'waiting' && s.status?.message].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${isCurrent ? ' is-current' : ''}${s.running ? '' : ' is-dormant'}` },
|
return el('li', { class: `dd-session-item is-${status.key}${isCurrent ? ' is-current' : ''}` },
|
||||||
el('button', { class: 'dd-session-open', type: 'button', title: s.dir || '', 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 ${s.running ? 'lt-dot-up' : 'lt-dot-idle'}` }),
|
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)),
|
||||||
el('span', { class: 'dd-session-actions' },
|
el('span', { class: 'dd-session-actions' },
|
||||||
|
|||||||
+67
-10
@@ -211,6 +211,24 @@ body.dd-sidebar-open .dd-sidebar {
|
|||||||
.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); }
|
||||||
|
.dd-session-item.is-waiting { border-color: rgba(255, 45, 85, 0.45); background: var(--accent-red-dim); }
|
||||||
|
.dd-session-item.is-waiting .dd-session-meta { color: var(--accent-red); }
|
||||||
|
.dd-session-item.is-working .dd-session-meta { color: var(--accent-amber); }
|
||||||
|
|
||||||
|
/* Header chips: current session's Claude state, and sessions needing you. */
|
||||||
|
.dd-state-chip { display: none; }
|
||||||
|
.dd-state-chip[data-state="working"] { display: inline-flex; color: var(--accent-amber); background: var(--accent-amber-dim); }
|
||||||
|
.dd-state-chip[data-state="waiting"] { display: inline-flex; color: var(--accent-red); background: var(--accent-red-dim); text-shadow: var(--glow-red); }
|
||||||
|
.dd-state-chip[data-state="idle"] { display: inline-flex; color: var(--accent-green); background: var(--accent-green-dim); }
|
||||||
|
.dd-attention {
|
||||||
|
display: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--accent-red);
|
||||||
|
background: var(--accent-red-dim);
|
||||||
|
animation: lt-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.dd-attention.is-active { display: inline-flex; }
|
||||||
|
|
||||||
.dd-session-actions {
|
.dd-session-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -275,24 +293,63 @@ body.dd-sidebar-open .dd-sidebar {
|
|||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- stub widgets ------------------------------------------------------------ */
|
/* ---- ops alerts --------------------------------------------------------------- */
|
||||||
|
.dd-alert-count { margin-left: auto; }
|
||||||
|
.dd-alert-count:empty { display: none; }
|
||||||
|
.dd-alert-list { display: flex; flex-direction: column; gap: var(--space-sm); }
|
||||||
.dd-alert {
|
.dd-alert {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: var(--space-sm);
|
gap: 3px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-left: 2px solid var(--accent-amber);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
}
|
||||||
.dd-alert-text { flex: 1 1 auto; }
|
.dd-alert:has(.lt-chip-critical) { border-left-color: var(--accent-red); }
|
||||||
|
.dd-alert-head { display: flex; align-items: center; gap: var(--space-sm); min-width: 0; }
|
||||||
|
.dd-alert-name { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dd-alert-age { margin-left: auto; white-space: nowrap; }
|
||||||
|
.dd-alert-instances { overflow-wrap: anywhere; }
|
||||||
|
.dd-alert-summary { color: var(--text-secondary); }
|
||||||
|
.dd-alert .dd-ask { align-self: flex-start; margin-top: 2px; }
|
||||||
|
.dd-all-clear { display: flex; align-items: center; gap: var(--space-sm); color: var(--accent-green); font-size: 0.74rem; }
|
||||||
|
|
||||||
.dd-context-grid { margin-top: var(--space-sm); }
|
.dd-context-grid { margin-top: var(--space-sm); }
|
||||||
|
|
||||||
[data-state="stub"] .lt-section-body,
|
[data-state="stub"] .lt-section-body { opacity: 0.75; }
|
||||||
#widget-context[data-state="empty"] .lt-kv-grid,
|
|
||||||
#widget-repo[data-state="empty"] .lt-kv-grid { opacity: 0.6; }
|
|
||||||
|
|
||||||
.lt-status a { color: inherit; text-shadow: none; }
|
/* ---- file drop / upload ------------------------------------------------------ */
|
||||||
.dd-widget .lt-kv-val { overflow-wrap: anywhere; }
|
.dd-drop-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2px dashed var(--accent-cyan);
|
||||||
|
background: rgba(0, 212, 255, 0.08);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.dd-drop-overlay[hidden] { display: none; }
|
||||||
|
.dd-upload-status {
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
bottom: 16px;
|
||||||
|
z-index: 6;
|
||||||
|
max-width: 70%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--accent-cyan-border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
box-shadow: var(--box-glow-cyan);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.dd-upload-status[hidden] { display: none; }
|
||||||
|
.dd-upload-status.is-error { border-color: rgba(255, 45, 85, 0.5); color: var(--accent-red); }
|
||||||
|
|
||||||
/* ---- overlay ----------------------------------------------------------------- */
|
/* ---- overlay ----------------------------------------------------------------- */
|
||||||
.dd-overlay {
|
.dd-overlay {
|
||||||
|
|||||||
Reference in New Issue
Block a user