diff --git a/Makefile b/Makefile index bc129ba..e198660 100644 --- a/Makefile +++ b/Makefile @@ -41,7 +41,7 @@ install: all install -m 0755 libexec/devdesk-picker libexec/devdesk-claude-run libexec/devdesk-session-hook \ libexec/devdesk-tmux-ready libexec/devdesk-serve libexec/devdesk-statusline \ libexec/devdesk-deploy libexec/devdesk-state-hook libexec/devdesk-notify-matrix $(DESTDIR)$(LIBDIR)/ - install -m 0644 libexec/devdesk-files.sh $(DESTDIR)$(LIBDIR)/devdesk-files.sh + install -m 0644 libexec/devdesk-files.sh libexec/devdesk-inbox.sh $(DESTDIR)$(LIBDIR)/ install -m 0755 build/ttyd $(DESTDIR)$(LIBDIR)/bin/ttyd install -m 0644 dist/index.html share/tmux.conf share/claude-settings.json $(DESTDIR)$(SHAREDIR)/ install -d $(DESTDIR)$(SHAREDIR)/assets diff --git a/README.md b/README.md index 743a6a8..377dd0e 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ The tmux server runs in its own unit, so every Claude process lives in **that** - The sidebar shows it with context % and a context bar; the session chip's dot shows the current session's state. - A "… needs you" chip jumps to other sessions that are waiting, and a desktop notification fires when one starts waiting. - The **🔔 inbox** lists what needs you (waiting sessions, firing Grafana alerts) and what happened while you were away (a background session finished or started waiting, CI passed or failed); its badge counts them. + - **Answer from the inbox:** a session waiting on a permission shows the exact command/file and **Allow / Deny** (the key is pressed only if that prompt is still on screen); other waits and finished sessions offer **Peek & reply**: the last exchange and a reply box. - **Matrix "Claude needs you":** when a session has waited for `DEVDESK_MATRIX_DELAY` seconds (30) and no browser tab or SSH client is attached to it, `devdesk-notify-matrix` posts one message per wait, with a link, to the hookshot webhook URL in `/etc/devdesk/matrix-webhook` (mode 600; no file = off). - **File & image drop:** drag files onto the terminal or paste a screenshot. - They're uploaded through ttyd to `/var/lib/devdesk/uploads//` (25 MB max, pruned after 7 days), and the path is pasted into Claude. diff --git a/bin/devdesk b/bin/devdesk index 1f4d506..36b6625 100755 --- a/bin/devdesk +++ b/bin/devdesk @@ -19,6 +19,8 @@ set -euo pipefail . "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" # shellcheck source=libexec/devdesk-files.sh . "$DEVDESK_LIBEXEC/devdesk-files.sh" +# shellcheck source=libexec/devdesk-inbox.sh +. "$DEVDESK_LIBEXEC/devdesk-inbox.sh" usage() { sed -n '2,12s/^# \{0,1\}//p' "$0" @@ -325,6 +327,20 @@ cmd_attach() { dd_linger return ;; + --peek) + shift + cmd_peek "$@" + dd_linger + return + ;; + --answer | --reply) + local action=${1#--} + shift + "cmd_$action" "$@" + ok_json + dd_linger + return + ;; --new | --rename | --end) local action=${1#--} shift diff --git a/debian/changelog b/debian/changelog index c2b6f2c..e3abf4e 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,23 @@ +devdesk (0.8.1) bookworm; urgency=medium + + * Answer sessions from the 🔔 inbox without switching to them: + - Allow / Deny (#22): a PermissionRequest hook records what Claude wants + to run (tool, command/file/URL, Claude's description); the inbox shows + it with Allow, Deny and Open. The key is pressed in that session's + Claude window only if the same wait is still on and the permission + prompt is on screen, so a stale button never types into Claude. + - Peek & reply (#23): the last thing you asked and Claude's latest answer + (from the session's transcript), and a reply box; the reply is pasted + as one bracketed paste and sent. Available for sessions that wait for + input and from "finished" entries. + - Matrix "needs you" messages include the command. + * New attach commands: --answer SESSION SINCE allow|deny, --peek SESSION, + --reply SESSION BYTES (text streamed like uploads). + * Sessions pick up the new PermissionRequest hook when Claude next starts + (/exit, then ⏎). + + -- Jared Vititoe Sat, 10 Oct 2026 20:22:37 -0400 + devdesk (0.8.0) bookworm; urgency=medium * Edit and save files in the Files pane (#20): diff --git a/libexec/devdesk-inbox.sh b/libexec/devdesk-inbox.sh new file mode 100644 index 0000000..179d376 --- /dev/null +++ b/libexec/devdesk-inbox.sh @@ -0,0 +1,111 @@ +# shellcheck shell=bash disable=SC2016 # single-quoted jq filters use jq variables +# Answer a session without switching to it (devdesk#22, #23), sourced by +# bin/devdesk and reached through `devdesk attach --answer|--peek|--reply`. + +dd_state_file() { + printf '%s/%s.json\n' "$DEVDESK_STATUS_DIR" "$1" +} + +# The Claude window's visible screen (not the scrollback). +dd_claude_screen() { + dd_tmux capture-pane -p -J -t "=$1:claude" 2>/dev/null +} + +# --answer SESSION SINCE allow|deny: press the permission prompt's key in that +# session's Claude window. Only when the same wait is still on (SINCE) and the +# prompt is actually on screen, so a stale button never types into Claude. +cmd_answer() { + [ $# -eq 3 ] || dd_die "usage: devdesk attach --answer SESSION SINCE allow|deny" + local session=$1 since=$2 choice=$3 file + dd_valid_name "$session" || dd_die "invalid session name: $session" + [[ $since =~ ^[0-9]+$ ]] || dd_die "bad wait id: $since" + case $choice in allow | deny) ;; *) dd_die "answer allow or deny" ;; esac + if ! { dd_server_up && dd_tmux has-session -t "=$session" 2>/dev/null; }; then dd_die "session '$session' isn't running"; fi + file=$(dd_state_file "$session") + if [ "$(jq -rM '"\(.state) \(.since) \(.request != null)"' "$file" 2>/dev/null)" != "waiting $since true" ]; then + dd_die "nothing to answer: it was already answered" + fi + # Every permission prompt lists "1. Yes" first (Bash, edits, fetches, MCP). + if ! dd_claude_screen "$session" | tail -n 40 | grep -Eq '^[[:space:]]*(❯[[:space:]]*)?1\.[[:space:]]+Yes'; then + dd_die "the permission prompt isn't on screen any more" + fi + if [ "$choice" = allow ]; then + dd_tmux send-keys -t "=$session:claude" 1 + else + dd_tmux send-keys -t "=$session:claude" Escape + fi + # A denial doesn't fire a hook; the turn ends there. An approval is followed + # by PostToolUse, which takes it from here. + local tmp + tmp=$(mktemp "$DEVDESK_STATUS_DIR/.$session.XXXXXX") || return 0 + if jq -cMn --arg s "$([ "$choice" = allow ] && echo working || echo idle)" --arg m "$([ "$choice" = allow ] && echo "Allowed from DevDesk" || echo "Denied from DevDesk")" \ + '{state: $s, since: (now | floor), event: "DevDeskAnswer", message: $m}' >"$tmp"; then + mv -f "$tmp" "$file" + else + rm -f "$tmp" + fi +} + +# The session's conversation file, from the id the SessionStart hook recorded. +dd_transcript() { + local id + id=$(dd_registry_get "$1" claude_session_id) + [[ $id =~ ^[0-9a-f-]{36}$ ]] || return 1 + compgen -G "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects/*/$id.jsonl" | head -n1 +} + +# --peek SESSION: what the session is doing and the last exchange, from the end +# of its transcript (the last assistant text and the last thing you asked). +cmd_peek() { + [ $# -eq 1 ] || dd_die "usage: devdesk attach --peek SESSION" + local session=$1 transcript status + dd_valid_name "$session" || dd_die "invalid session name: $session" + status=$(jq -cM . "$(dd_state_file "$session")" 2>/dev/null) || status=null + transcript=$(dd_transcript "$session") || transcript="" + if [ -z "$transcript" ] || [ ! -r "$transcript" ]; then + jq -cMn --arg s "$session" --argjson st "${status:-null}" '{session: $s, status: $st, assistant: null, user: null}' + return + fi + tail -n 400 -- "$transcript" | jq -cMs --arg s "$session" --argjson st "${status:-null}" ' + def text: if type == "string" then . else ([.[]? | select(.type == "text") | .text] | join("\n")) end; + [.[] | select(.isSidechain != true and .isMeta != true)] as $all + | ([$all[] | select(.type == "assistant") | {text: (.message.content | text), at: .timestamp} | select(.text != "")] | last) as $a + | ([$all[] | select(.type == "user" and (.message.content | type == "string" or (any(.[]?; .type == "text")))) + | {text: (.message.content | text), at: .timestamp} + | select(.text != "" and (.text | startswith("<") | not))] | last) as $u + | {session: $s, status: $st, + assistant: (if $a then {text: ($a.text | .[-4000:]), at: $a.at, truncated: ($a.text | length > 4000)} else null end), + user: (if $u then {text: ($u.text | .[-600:]), at: $u.at} else null end)}' 2>/dev/null || + jq -cMn --arg s "$session" --argjson st "${status:-null}" '{session: $s, status: $st, assistant: null, user: null}' +} + +# --reply SESSION BYTES: type a message into the session's Claude prompt and +# send it. The text arrives as base64 after the READY line (like uploads) and is +# pasted as one bracketed paste, so newlines in it don't send it early. +cmd_reply() { + [ $# -eq 2 ] || dd_die "usage: devdesk attach --reply SESSION BYTES" + local session=$1 size=$2 file tmp b64len + dd_valid_name "$session" || dd_die "invalid session name: $session" + if ! [[ $size =~ ^[0-9]+$ ]] || [ "$size" -lt 1 ] || [ "$size" -gt 16384 ]; then dd_die "a reply is 1 to 16384 bytes"; fi + if ! { dd_server_up && dd_tmux has-session -t "=$session" 2>/dev/null; }; then dd_die "session '$session' isn't running"; fi + file=$(dd_state_file "$session") + if [ "$(jq -rM '"\(.state) \(.request != null)"' "$file" 2>/dev/null)" = "waiting true" ]; then + dd_die "Claude is asking for a permission: answer that first" + fi + tmp=$(mktemp) + trap 'rm -f "$tmp"' HUP INT TERM + # 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-REPLY-READY\r\n' + head -c "$b64len" | base64 -d >"$tmp" 2>/dev/null || true + stty sane 2>/dev/null || true + if [ "$(stat -c %s "$tmp")" != "$size" ]; then + rm -f "$tmp" + dd_die "reply incomplete" + fi + dd_tmux load-buffer -b devdesk-reply "$tmp" && dd_tmux paste-buffer -p -d -b devdesk-reply -t "=$session:claude" && + sleep 0.2 && dd_tmux send-keys -t "=$session:claude" Enter + rm -f "$tmp" + trap - HUP INT TERM +} diff --git a/libexec/devdesk-notify-matrix b/libexec/devdesk-notify-matrix index 6c2cdee..39e748b 100755 --- a/libexec/devdesk-notify-matrix +++ b/libexec/devdesk-notify-matrix @@ -27,6 +27,8 @@ attached=$(dd_tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev [ "${attached:-0}" = 0 ] || exit 0 message=$(jq -rM '.message // "Claude is waiting for your input"' "$state_file" 2>/dev/null) +detail=$(jq -rM '.request.detail // empty' "$state_file" 2>/dev/null) +[ -z "$detail" ] || message="$message: ${detail:0:300}" url="" # existing=1: if the session was ended by the time you tap, say so instead of # starting a new Claude there (devdesk#38). diff --git a/libexec/devdesk-state-hook b/libexec/devdesk-state-hook index 18b56f6..67f7f32 100755 --- a/libexec/devdesk-state-hook +++ b/libexec/devdesk-state-hook @@ -2,6 +2,9 @@ # 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 +# PermissionRequest -> waiting, with what Claude wants to run +# (tool + command/file/URL), for the inbox's +# Allow/Deny (devdesk#22) # Notification -> waiting (Claude needs input or a permission) # Stop, SessionStart -> idle # SessionEnd -> exited @@ -35,7 +38,7 @@ PostToolUse:Edit | PostToolUse:Write | PostToolUse:MultiEdit | PostToolUse:Noteb esac case $event in UserPromptSubmit | PostToolUse) state=working ;; -Notification) state=waiting ;; +Notification | PermissionRequest) state=waiting ;; Stop | SessionStart) state=idle ;; SessionEnd) state=exited ;; *) exit 0 ;; @@ -47,19 +50,33 @@ file="$dir/$session.json" if [ "$state" != waiting ] && [ -f "$file" ] && grep -q "\"state\":\"$state\"" "$file"; then exit 0 fi +# What a permission prompt is about. tool_use_id is null in PermissionRequest, +# so a wait is identified by its `since` instead. +request=null +if [ "$event" = PermissionRequest ]; then + request=$(jq -cM '(.tool_input // {}) as $i | {tool: (.tool_name // "a tool"), + detail: (($i.command // $i.file_path // $i.notebook_path // $i.url // $i.pattern // $i.query // $i.path // ($i | tostring)) | tostring | .[0:600]), + description: ($i.description // null)}' <<<"$input" 2>/dev/null) || request=null + message="Claude wants to use ${tool:-a tool}" +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` (and what the wait is about), 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) + if [ "$request" = null ]; then + request=$(jq -cM '.request // null' "$file" 2>/dev/null) || request=null + [ "$request" = null ] || message=$(jq -rM '.message // empty' "$file" 2>/dev/null) + fi 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" --argjson t "$since" \ - '{state: $s, since: $t, event: $e, message: (if $m == "" then null else $m end)}' >"$tmp"; then +if jq -cMn --arg s "$state" --arg m "$message" --arg e "$event" --argjson t "$since" --argjson r "${request:-null}" \ + '{state: $s, since: $t, event: $e, message: (if $m == "" then null else $m end)} + (if $r then {request: $r} else {} end)' >"$tmp"; then mv -f "$tmp" "$file" else rm -f "$tmp" diff --git a/share/claude-settings.json b/share/claude-settings.json index 464ad76..1bc671c 100644 --- a/share/claude-settings.json +++ b/share/claude-settings.json @@ -73,6 +73,17 @@ } ] } + ], + "PermissionRequest": [ + { + "hooks": [ + { + "type": "command", + "command": "/usr/lib/devdesk/devdesk-state-hook", + "timeout": 5 + } + ] + } ] }, "statusLine": { diff --git a/tests/attach.bats b/tests/attach.bats index a628a84..d0f7684 100644 --- a/tests/attach.bats +++ b/tests/attach.bats @@ -675,3 +675,68 @@ fs_write() { # SESSION PATH BASE CONTENT-FILE [ "$status" -ne 0 ] [ "$(cat "$p/docs/notes/new.md")" = hello ] } + +@test "permission requests are recorded and answered only while their prompt shows (#22)" { + start_server + devdesk new alpha + claude_args alpha >/dev/null + 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":"PermissionRequest","tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/x","description":"Remove x"}}' | hook + echo '{"hook_event_name":"Notification","message":"Claude needs your permission","notification_type":"permission_prompt"}' | hook + st="$DEVDESK_STATE_DIR/state/alpha.json" + [ "$(jq -r '[.state, .request.tool, .request.detail, .request.description, .message] | @tsv' "$st")" = \ + "$(printf 'waiting\tBash\trm -rf /tmp/x\tRemove x\tClaude wants to use Bash')" ] + since=$(jq -r .since "$st") + # The fake Claude window shows no prompt: refused, nothing typed. + run devdesk attach --answer alpha "$since" allow + [ "$status" -ne 0 ] + [[ $output == *"isn't on screen"* ]] + # Put a prompt on screen (what Claude draws), then allow. + tmux -S "$DEVDESK_SOCKET" respawn-window -k -t =alpha:claude "printf ' Do you want to proceed?\n ❯ 1. Yes\n 2. No\n'; read -r -n1 k; echo \"got:\$k\" > '$TMP/key'; sleep 30" + sleep 0.5 + run devdesk attach --answer alpha 999 allow + [ "$status" -ne 0 ] + [[ $output == *"already answered"* ]] + run devdesk attach --answer alpha "$since" allow + [ "$status" -eq 0 ] + sleep 0.5 + [ "$(cat "$TMP/key")" = "got:1" ] + [ "$(jq -r .state "$st")" = working ] + # Answered: a second click is refused. + run devdesk attach --answer alpha "$since" deny + [ "$status" -ne 0 ] +} + +@test "peek shows the last exchange; reply pastes into the Claude window (#23)" { + start_server + devdesk new alpha + claude_args alpha >/dev/null + export CLAUDE_CONFIG_DIR="$TMP/claude" + id=11111111-2222-3333-4444-555555555555 + mkdir -p "$CLAUDE_CONFIG_DIR/projects/-x" + cat >"$CLAUDE_CONFIG_DIR/projects/-x/$id.jsonl" <<'JSONL' +{"type":"user","message":{"role":"user","content":"Please upgrade PBS"},"timestamp":"2026-10-10T10:00:00Z"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{}}]},"timestamp":"2026-10-10T10:00:05Z"} +{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"ok"}]},"timestamp":"2026-10-10T10:00:06Z"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"PBS is on 4.1. Reboot now?"}]},"timestamp":"2026-10-10T10:00:09Z"} +JSONL + jq --arg id "$id" '.sessions.alpha.claude_session_id = $id' "$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json" + run devdesk attach --peek alpha + [ "$status" -eq 0 ] + [ "$(jq -r '[.assistant.text, .user.text] | @tsv' <<<"$output")" = "$(printf 'PBS is on 4.1. Reboot now?\tPlease upgrade PBS')" ] + # reply: lands in the Claude window as one paste + Enter + tmux -S "$DEVDESK_SOCKET" respawn-window -k -t =alpha:claude "IFS= read -r line; printf '%s' \"\$line\" > '$TMP/got'; sleep 30" + sleep 0.3 + printf 'yes, reboot it' >"$TMP/msg" + run bash -c "base64 -w0 '$TMP/msg' | '$REPO/bin/devdesk' attach --reply alpha 14" + [ "$status" -eq 0 ] + sleep 0.5 + [ "$(cat "$TMP/got")" = "yes, reboot it" ] + # not while a permission prompt waits + mkdir -p "$DEVDESK_STATE_DIR/state" + printf '{"state":"waiting","since":1,"request":{"tool":"Bash"}}\n' >"$DEVDESK_STATE_DIR/state/alpha.json" + run bash -c "base64 -w0 '$TMP/msg' | '$REPO/bin/devdesk' attach --reply alpha 14" + [ "$status" -ne 0 ] + [[ $output == *"answer that first"* ]] +} diff --git a/web/src/connection.js b/web/src/connection.js index d910481..e751c56 100644 --- a/web/src/connection.js +++ b/web/src/connection.js @@ -215,6 +215,21 @@ async function uploadNow(session, file, onProgress) { return result.path; } +// Answer a session from the inbox (devdesk#22, #23). +export function answerPrompt(session, since, choice) { + return sessionAction('answer', session, String(since), choice); +} + +export async function peekSession(session) { + return parseListOutput(await runCommand(['--peek', session], 15000)); +} + +export async function replyTo(session, text) { + const bytes = new TextEncoder().encode(text); + const result = await limited(() => streamIn(['--reply', session, String(bytes.length)], 'DEVDESK-REPLY-READY', new Blob([bytes]))); + if (!result.ok) throw new Error('not sent'); +} + // Save a file from the editor (devdesk#20): `--fs-write SESSION PATH BYTES BASE` // with the content streamed after the ready line. Resolves the server's JSON: // {ok: true, sha, size, mtime} or {ok: false, conflict: true, sha} when the file diff --git a/web/src/inbox.js b/web/src/inbox.js index e0c13ef..c385001 100644 --- a/web/src/inbox.js +++ b/web/src/inbox.js @@ -5,6 +5,8 @@ import { el } from './dom.js'; import { ago } from './sessions.js'; +import { answerPrompt, peekSession, replyTo } from './connection.js'; +import { toast } from './ui.js'; const KEY = 'devdesk.inbox'; const MAX = 30; @@ -65,6 +67,8 @@ export class Inbox { if (open) this.panel.removeAttribute('aria-hidden'); else this.panel.setAttribute('aria-hidden', 'true'); this.button.setAttribute('aria-expanded', String(open)); + if (!open) this.peeking = null; + this.panel.classList.toggle('is-peek', false); if (open) this.render(); } @@ -113,7 +117,84 @@ export class Inbox { if (this.isOpen) this.renderPanel(); } + // ------------------------------------------------------------ answer from here + + async answer(session, since, choice, button) { + button.disabled = true; + try { + await answerPrompt(session, since, choice); + toast.success(choice === 'allow' ? `Allowed in ${session}` : `Denied in ${session}`); + this.hooks.refresh?.(); + } catch (error) { + toast.error(`${session}: ${error.message}`); + this.hooks.refresh?.(); + } finally { + button.disabled = false; + } + } + + // Peek: the last exchange of a session, and a reply box. + async peek(session) { + this.peeking = session; + this.panel.classList.add('is-peek'); + const body = el('div', { class: 'dd-peek' }, el('div', { class: 'dd-notif-empty' }, 'Loading…')); + this.list.replaceChildren(body); + let data; + try { + data = await peekSession(session); + } catch (error) { + body.replaceChildren(el('div', { class: 'dd-notif-empty' }, error.message)); + return; + } + if (this.peeking !== session) return; + const input = el('textarea', { class: 'lt-input dd-peek-input', rows: '3', placeholder: `Reply to ${session}… (Ctrl+Enter sends)`, 'aria-label': `Reply to ${session}` }); + const send = el('button', { type: 'button', class: 'lt-btn lt-btn-primary lt-btn-sm' }, 'Send'); + const submit = async () => { + const text = input.value.trim(); + if (!text) return; + send.disabled = true; + try { + await replyTo(session, text); + toast.success(`Sent to ${session}`); + this.peeking = null; + this.setOpen(false); + this.hooks.refresh?.(); + } catch (error) { + toast.error(`${session}: ${error.message}`); + } finally { + send.disabled = false; + } + }; + send.addEventListener('click', submit); + input.addEventListener('keydown', (event) => { + if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { + event.preventDefault(); + submit(); + } + event.stopPropagation(); // keys belong to the reply box, not the terminal or shortcuts + }); + const status = data.status; + const waitingRequest = status?.state === 'waiting' && status.request; + body.replaceChildren( + el('div', { class: 'dd-peek-head' }, + el('button', { type: 'button', class: 'dd-peek-back', onclick: () => { this.peeking = null; this.panel.classList.remove('is-peek'); this.renderPanel(); } }, '←'), + el('span', { class: 'dd-peek-title' }, session), + el('span', { class: 'dd-peek-state' }, status?.state ? `${status.state}${status.since ? ` · ${ago(status.since)}` : ''}` : '')), + data.user ? el('div', { class: 'dd-peek-user' }, el('span', { class: 'dd-peek-label' }, 'You'), data.user.text) : null, + el('div', { class: 'dd-peek-assistant' }, + el('span', { class: 'dd-peek-label' }, 'Claude'), + data.assistant ? `${data.assistant.truncated ? '…' : ''}${data.assistant.text}` : 'Nothing yet.'), + waitingRequest + ? el('div', { class: 'dd-notif-empty' }, 'Claude is asking for a permission: answer it from the list, or open the session.') + : el('div', { class: 'dd-peek-reply' }, input, el('div', { class: 'dd-peek-actions' }, + el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: () => { this.setOpen(false); this.hooks.openSession(session); } }, 'Open session'), + send))); + if (!waitingRequest) input.focus(); + body.querySelector('.dd-peek-assistant').scrollTop = 1e9; + } + renderPanel() { + if (this.peeking) return; // the peek view stays until you go back const close = (run) => () => { this.setOpen(false); run(); @@ -136,11 +217,31 @@ export class Inbox { if (this.waiting.length) { nodes.push(el('div', { class: 'dd-notif-section' }, 'Needs you')); for (const s of this.waiting) { - nodes.push(item({ + const request = s.status?.request; + const node = item({ dot: 'red', unread: true, title: `${s.name}: ${s.status?.message || 'Claude is waiting for your input'}`, meta: s.status?.since ? `waiting ${ago(s.status.since)}` : 'waiting', onclick: close(() => this.hooks.openSession(s.name)), - })); + }); + const stop = (run) => (event) => { + event.stopPropagation(); + run(event.currentTarget); + }; + const body = node.querySelector('.lt-notif-item-body'); + if (request) { + body.append( + request.description ? el('div', { class: 'dd-request-desc' }, request.description) : null, + el('code', { class: 'dd-request' }, request.detail || request.tool), + el('div', { class: 'dd-request-actions' }, + el('button', { type: 'button', class: 'lt-btn lt-btn-primary lt-btn-sm', onclick: stop((b) => this.answer(s.name, s.status.since, 'allow', b)) }, 'Allow'), + el('button', { type: 'button', class: 'lt-btn lt-btn-danger lt-btn-sm', onclick: stop((b) => this.answer(s.name, s.status.since, 'deny', b)) }, 'Deny'), + el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: stop(() => { this.setOpen(false); this.hooks.openSession(s.name); }) }, 'Open'))); + } else { + body.append(el('div', { class: 'dd-request-actions' }, + el('button', { type: 'button', class: 'lt-btn lt-btn-sm', onclick: stop(() => this.peek(s.name)) }, 'Peek & reply'), + el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: stop(() => { this.setOpen(false); this.hooks.openSession(s.name); }) }, 'Open'))); + } + nodes.push(node); } } if (this.alerts.length) { @@ -160,7 +261,7 @@ export class Inbox { const kind = KINDS[e.kind] ?? { tone: 'cyan' }; // An entry about a session that has since ended must not bring it back (devdesk#38). const ended = !e.url && e.session && this.known && !this.known.has(e.session); - nodes.push(item({ + const recentNode = item({ dot: e.read || ended ? 'read' : kind.tone, unread: !e.read && !ended, title: e.text, meta: `${when(e.at)}${ended ? ' · session ended' : ''}`, onclick: close(() => { @@ -170,7 +271,12 @@ export class Inbox { else if (ended) this.hooks.ended?.(e.session); else if (e.session) this.hooks.openSession(e.session); }), - })); + }); + if (e.kind === 'finished' && !ended) { + recentNode.querySelector('.lt-notif-item-body').append(el('div', { class: 'dd-request-actions' }, + el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: (event) => { event.stopPropagation(); this.peek(e.session); } }, 'Peek & reply'))); + } + nodes.push(recentNode); } } if (!nodes.length) nodes.push(el('div', { class: 'dd-notif-empty' }, 'Nothing needs you. Sessions that finish or wait while you\'re elsewhere show up here.')); diff --git a/web/src/main.js b/web/src/main.js index 60b01e3..e976483 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -900,6 +900,7 @@ function wireChrome() { hooks: { openSession: (name) => connect(name, { existing: true }), ended: (name) => toast.info(`${name} has ended; its conversation is still in Claude's history`), + refresh: () => sessionsPanel.reload(), showAlerts: () => { setSidebar(true); $('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' }); diff --git a/web/styles.css b/web/styles.css index 8f58997..7e50018 100644 --- a/web/styles.css +++ b/web/styles.css @@ -947,3 +947,32 @@ html[data-theme="light"] .dd-files.is-narrow .dd-explorer { box-shadow: 8px 0 24 .dd-dialog.is-wide .lt-modal-body { padding: 0; } .dd-compare { display: flex; height: min(64vh, 640px); } .dd-compare > .cm-editor { flex: 1; min-width: 0; } + +/* ---- inbox: answer & peek (devdesk#22, #23) --------------------------------------------- */ +.dd-inbox-panel.is-peek { width: min(460px, calc(100vw - 16px)); } +.dd-request-desc { margin-top: 4px; color: var(--text-secondary); font-size: 0.7rem; } +.dd-request { + display: block; + max-height: 7.5em; + margin-top: 4px; + padding: 4px 6px; + overflow: auto; + border-left: 2px solid var(--accent-amber); + background: var(--bg-terminal); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 0.7rem; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.dd-request-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; } +.dd-peek { display: flex; flex-direction: column; gap: 8px; padding: 8px 10px 10px; } +.dd-peek-head { display: flex; align-items: center; gap: 8px; } +.dd-peek-back { padding: 0 6px; border: 1px solid var(--border-color); background: none; color: var(--accent-cyan); cursor: pointer; } +.dd-peek-title { flex: 1; color: var(--text-primary); font-weight: 700; font-size: 0.8rem; } +.dd-peek-state { color: var(--text-muted); font-size: 0.66rem; } +.dd-peek-label { display: block; margin-bottom: 2px; color: var(--accent-orange); font-size: 0.6rem; letter-spacing: 0.1em; text-transform: uppercase; } +.dd-peek-user { padding: 6px 8px; border-left: 2px solid var(--border-color); color: var(--text-secondary); font-size: 0.72rem; white-space: pre-wrap; overflow-wrap: anywhere; } +.dd-peek-assistant { max-height: 38vh; padding: 6px 8px; overflow: auto; border-left: 2px solid var(--accent-cyan); background: var(--bg-terminal); color: var(--text-primary); font-size: 0.74rem; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; } +.dd-peek-input { width: 100%; min-height: 4.5em; resize: vertical; font-family: var(--font-mono); font-size: 0.76rem; } +.dd-peek-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; }