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:
2026-10-10 20:22:37 -04:00
co-authored by Claude Opus 5.5
parent 66321f4748
commit 31e3eb2c2c
13 changed files with 403 additions and 9 deletions
+1 -1
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
+20
View File
@@ -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):
+111
View File
@@ -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
}
+2
View File
@@ -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).
+21 -4
View File
@@ -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"
+11
View File
@@ -73,6 +73,17 @@
}
]
}
],
"PermissionRequest": [
{
"hooks": [
{
"type": "command",
"command": "/usr/lib/devdesk/devdesk-state-hook",
"timeout": 5
}
]
}
]
},
"statusLine": {
+65
View File
@@ -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"* ]]
}
+15
View File
@@ -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
View File
@@ -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.'));
+1
View File
@@ -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' });
+29
View File
@@ -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; }