feat: v0.8.1 answer sessions from the inbox: allow/deny, peek & reply
- PermissionRequest hook: the state file records tool, detail and description of a pending permission; Notification keeps it. - devdesk attach --answer (presses 1/Esc only if the same wait is on and the prompt is visible), --peek (last exchange from the transcript), --reply (bracketed paste + Enter, streamed like uploads). - Inbox: Allow/Deny/Open for permission waits; Peek & reply for other waits and finished sessions. Matrix messages include the command. Closes #22 Closes #23 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/<session>/` (25 MB max, pruned after 7 days), and the path is pasted into Claude.
|
||||
|
||||
+16
@@ -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
|
||||
|
||||
Vendored
+20
@@ -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 <jjvititoe1@gmail.com> Sat, 10 Oct 2026 20:22:37 -0400
|
||||
|
||||
devdesk (0.8.0) bookworm; urgency=medium
|
||||
|
||||
* Edit and save files in the Files pane (#20):
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -73,6 +73,17 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PermissionRequest": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "/usr/lib/devdesk/devdesk-state-hook",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"statusLine": {
|
||||
|
||||
@@ -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"* ]]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+110
-4
@@ -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.'));
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user