Compare commits

...
3 Commits
Author SHA1 Message Date
jaredandClaude Opus 5.5 a92d10ace3 feat: v0.8.1 answer sessions from the inbox: allow/deny, peek & reply
build-deb / Lint, test, package (push) Successful in 2m4s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped
- 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>
2026-10-10 20:22:37 -04:00
jaredandClaude Opus 5.5 66321f4748 feat: v0.8.0 edit and save files with conflict detection
build-deb / Lint, test, package (push) Successful in 1m57s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped
- devdesk attach --fs-write SESSION PATH BYTES BASESHA|new: streamed like
  uploads, temp file in the same folder, sha re-check (conflict reply instead
  of overwriting), mode/owner kept, atomic mv; new files with folders, never
  outside the session folder or through a symlink out of it.
- Editor: editable CodeMirror for UTF-8 text, dirty tracking, Ctrl+S,
  Save/Revert, conflict dialog with a disk-vs-yours diff, changed-on-disk
  banner, drafts in localStorage (flushed on pagehide), close/leave guards,
  New file.
- Upload streaming factored into streamIn(), shared by uploads and saves.

Closes #20

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:10:45 -04:00
jaredandClaude Opus 5.5 927920c162 fix: old links never re-create ended sessions
build-deb / Lint, test, package (push) Successful in 1m56s
build-deb / Deploy to CT 126 (push) Successful in 24s
build-deb / Notify on failure (push) Skipped
Inbox entries, the switcher, palette switches, the attention chip and Matrix
links (?arg=NAME&existing=1) attach with --existing; an ended session gets an
explanation and an explicit "Start again". Inbox entries for ended sessions
can't resurrect them; entries expire after 7 days.

Closes #38

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:57:52 -04:00
18 changed files with 1044 additions and 60 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
+3 -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.
@@ -46,7 +47,8 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
- **Ask Claude** types an investigation prompt into the current session without sending it.
- Configure `DEVDESK_GRAFANA_URL`, plus a Viewer token in `DEVDESK_GRAFANA_TOKEN_FILE`.
- **Repo / CI panel:** branch, ahead/behind, uncommitted changes, HEAD's Gitea CI status and open PRs for the session's folder. When CI fails, **Ask Claude to fix CI** types a prompt to read the failed job's log.
- **Files** (Ctrl+Shift+E), laid out like VS Code: an explorer tree (git colours, gitignored entries dimmed) and an editor with tabs (a click opens a preview tab, a double-click keeps it), **Go to file** (Ctrl+P in the pane), markdown previews (sanitized, with relative images), images, and a Changes view of uncommitted files with line counts, a **C** badge on files Claude edited, and diffs against HEAD. **@Claude** types `@path` into the prompt. Tabs and open folders are remembered per session. Read-only for now. Paths can't leave the session folder. The editor bundle (`/usr/share/devdesk/assets/editor.js`) loads over ttyd the first time and is cached by the browser.
- **Files** (Ctrl+Shift+E), laid out like VS Code: an explorer tree (git colours, gitignored entries dimmed) and an editor with tabs (a click opens a preview tab, a double-click keeps it), **Go to file** (Ctrl+P in the pane), markdown previews (sanitized, with relative images), images, and a Changes view of uncommitted files with line counts, a **C** badge on files Claude edited, and diffs against HEAD. **@Claude** types `@path` into the prompt. Tabs and open folders are remembered per session. Paths can't leave the session folder.
- **Editing:** text files are editable; ● marks unsaved tabs; Ctrl+S (or Save) writes atomically and keeps the file's mode and owner. If the file changed on disk since you opened it (Claude edited it), saving asks: Compare (a diff of disk vs yours), Overwrite or Cancel; a change on disk while you edit shows a banner instead of replacing your text. Unsaved edits are kept as drafts in the browser, so a reload or a session switch doesn't lose them. + creates a file (folders as needed). The editor bundle (`/usr/share/devdesk/assets/editor.js`) loads over ttyd the first time and is cached by the browser.
- **Context:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use and cost to `/var/lib/devdesk/context/<session>.json`. The Sessions panel shows it as a bar (cost in the tooltip), and Claude's own footer shows a one-line summary.
- **Light mode:** the terminal switches palettes with the page. DevDesk starts Claude with its `dark-ansi` theme so Claude draws with the terminal's 16 colours, and the light palette maps bright white to ink; tmux and the status line use ANSI colours too. A session picks up Claude's theme the next time Claude starts in it (`/exit`, then ⏎ resumes the same conversation).
- **Phone:** a key bar replaces the shortcut hints (Esc, Tab, ⇧Tab, ^C, ↑, ↓, ⏎, Claude/Shell window), and the panels open as a drawer.
+17 -1
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"
@@ -318,13 +320,27 @@ cmd_attach() {
dd_linger
return
;;
--fs-list | --fs-find | --fs-read | --git-status | --git-show | --asset)
--fs-list | --fs-find | --fs-read | --fs-write | --git-status | --git-show | --asset)
local action=${1#--}
shift
"cmd_${action//-/_}" "$@"
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
+56
View File
@@ -1,3 +1,59 @@
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):
- Text files open editable; ● marks unsaved tabs; Ctrl+S or Save writes
through `devdesk attach --fs-write`: a temp file in the same folder,
mode and owner kept, swapped in with mv.
- Conflict detection: the save carries the sha256 of the version you
opened; if Claude (or anyone) changed the file since, nothing is written
and you choose Compare (diff of disk vs yours), Overwrite or Cancel.
- A file that changes on disk while you edit it shows a banner (Compare,
Reload, Keep mine) instead of replacing your text; clean files still
reload in place.
- Unsaved edits are mirrored to the browser as drafts (flushed when the
page closes), so a reload, a crash or a session switch doesn't lose
them; a draft for a file that changed since offers Compare/Restore/
Discard.
- Closing a tab with unsaved changes asks Save / Don't save / Cancel;
leaving the page warns.
- + New file (missing folders are created; never outside the session
folder, and never through a symlink out of it).
- Markdown previews show unsaved Source edits. Latin-1 files stay
read-only.
-- Jared Vititoe <jjvititoe1@gmail.com> Sat, 10 Oct 2026 20:09:49 -0400
devdesk (0.7.3) bookworm; urgency=medium
* Fix (#38): links that mean "go to that session" no longer re-create one
that was ended. The 🔔 inbox, the session switcher, palette switches, the
"needs you" chip and Matrix links (now ?arg=NAME&existing=1) open sessions
existing-only; an ended one shows "NAME has ended" with an explicit
"Start NAME again". Inbox entries for ended sessions are marked as such,
and entries older than 7 days are dropped.
-- Jared Vititoe <jjvititoe1@gmail.com> Sat, 10 Oct 2026 19:57:52 -0400
devdesk (0.7.2) bookworm; urgency=medium
* Fix: Go to file failed in big non-git folders such as /root/code: the
+75
View File
@@ -190,6 +190,81 @@ cmd_git_show() {
rm -f "$tmp"
}
# --fs-write SESSION REL BYTES BASE (devdesk#20): save a file from the editor.
# The content arrives as base64 on stdin after the READY line (like uploads).
# BASE is the sha256 the editor loaded: if the file changed since, nothing is
# written and the reply is {conflict: true, sha: <current>}. BASE "new" creates
# a file (and missing folders) and refuses to overwrite one. Writes go to a
# temp file in the same folder, keep the mode and owner, and land with mv.
cmd_fs_write() {
[ $# -eq 4 ] || dd_die "usage: devdesk attach --fs-write SESSION PATH BYTES BASESHA|new"
local root rel=$2 size=$3 base=$4 target dir name tmp b64len actual current sha mtime
root=$(dd_session_root "$1")
if ! [[ $size =~ ^[0-9]+$ ]] || [ "$size" -gt "$DEVDESK_FS_MAX" ]; then
dd_die "too large: the editor saves files up to $((DEVDESK_FS_MAX / 1024)) KiB"
fi
[[ $base =~ ^([0-9a-f]{64}|new)$ ]] || dd_die "bad base: $base"
case $rel in /* | *$'\n'* | *$'\r'* | '' | */ | . | ..) dd_die "bad path: $rel" ;; esac
name=${rel##*/}
if [ "$name" = . ] || [ "$name" = .. ]; then dd_die "bad path: $rel"; fi
if [ "$base" = new ]; then
if [ -e "$root/$rel" ] || [ -L "$root/$rel" ]; then dd_die "already exists: $rel"; fi
case $rel in */*) dir=$root/${rel%/*} ;; *) dir=$root ;; esac
# Missing folders are created, but never through a symlink out of the
# root: check the deepest folder that exists before creating any.
local existing=$dir
while [ ! -e "$existing" ]; do existing=${existing%/*}; done
existing=${existing#"$root"}
dd_fs_path "$root" "${existing#/}" >/dev/null
mkdir -p -- "$dir" || dd_die "cannot create folder for $rel"
dir=${dir#"$root"}
dir=$(dd_fs_path "$root" "${dir#/}")
target="$dir/$name"
else
target=$(dd_fs_path "$root" "$rel")
[ -f "$target" ] || dd_die "not a file: $rel"
dir=${target%/*}
fi
tmp=$(mktemp --tmpdir="$dir" ".${name}.devdesk-XXXXXX") || dd_die "cannot write in ${dir#"$root"/}"
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-WRITE-READY\r\n'
if [ "$b64len" -gt 0 ]; then
head -c "$b64len" | base64 -d >"$tmp" 2>/dev/null || true
fi
stty sane 2>/dev/null || true
actual=$(stat -c %s "$tmp" 2>/dev/null || echo 0)
if [ "$actual" != "$size" ]; then
rm -f "$tmp"
dd_die "save incomplete ($actual of $size bytes)"
fi
if [ "$base" = new ]; then
if [ -e "$target" ]; then
rm -f "$tmp"
dd_die "already exists: $rel"
fi
chmod 0644 "$tmp"
else
current=$(sha256sum -- "$target" | cut -d' ' -f1)
if [ "$current" != "$base" ]; then
rm -f "$tmp"
trap - HUP INT TERM
jq -cMn --arg p "$rel" --arg h "$current" '{ok: false, conflict: true, path: $p, sha: $h}'
return
fi
chmod --reference="$target" "$tmp" 2>/dev/null || true
chown --reference="$target" "$tmp" 2>/dev/null || true
fi
mv -f -- "$tmp" "$target" || { rm -f "$tmp"; dd_die "cannot replace $rel"; }
trap - HUP INT TERM
sha=$(sha256sum -- "$target" | cut -d' ' -f1)
mtime=$(stat -c %Y -- "$target")
jq -cMn --arg p "${target#"$root"/}" --arg h "$sha" --argjson s "$size" --argjson m "$mtime" \
'{ok: true, path: $p, sha: $h, size: $s, mtime: $m}'
}
# --asset NAME: a lazily loaded part of the web UI (the file viewer's editor),
# shipped in $DEVDESK_ASSETS and cached by the browser.
cmd_asset() {
+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
}
+5 -1
View File
@@ -27,8 +27,12 @@ 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=""
[ -z "$DEVDESK_PUBLIC_URL" ] || url="${DEVDESK_PUBLIC_URL%/}/?arg=$session"
# existing=1: if the session was ended by the time you tap, say so instead of
# starting a new Claude there (devdesk#38).
[ -z "$DEVDESK_PUBLIC_URL" ] || url="${DEVDESK_PUBLIC_URL%/}/?arg=$session&existing=1"
body=$(jq -cMn --arg s "$session" --arg m "$message" --arg u "$url" --arg h "$(hostname)" \
'{session: $s, message: $m, url: (if $u == "" then null else $u end), host: $h,
text: ("Claude needs you in " + $s + ": " + $m + (if $u == "" then "" else " — " + $u end))}')
+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": {
+121 -1
View File
@@ -310,7 +310,7 @@ posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; }
sleep 2.5
[ "$(posts)" -eq 1 ]
run jq -r '[.session, .url, (.text | test("Claude needs you in alpha"))] | @tsv' "$TMP/posts"
[ "$output" = "$(printf 'alpha\thttps://dev.example.org/?arg=alpha\ttrue')" ]
[ "$output" = "$(printf 'alpha\thttps://dev.example.org/?arg=alpha&existing=1\ttrue')" ]
# answered within the delay: nothing is posted
echo '{"hook_event_name":"PostToolUse"}' | hook
@@ -620,3 +620,123 @@ blob_body() {
[ "$status" -eq 0 ]
[ "$(jq -r '[(.entries | length), .truncated] | @tsv' <<<"$output")" = "$(printf '5\ttrue')" ]
}
# Save through --fs-write the way the browser does: content as base64 on stdin.
fs_write() { # SESSION PATH BASE CONTENT-FILE
local size
size=$(stat -c %s "$4")
base64 -w0 "$4" | devdesk attach --fs-write "$1" "$2" "$size" "$3"
}
@test "fs-write saves atomically, keeps the mode, and refuses stale bases (#20)" {
p="$DEVDESK_PROJECTS_ROOT/alpha"
printf 'v1\n' >"$p/conf.sh"
chmod 0750 "$p/conf.sh"
base=$(sha256sum "$p/conf.sh" | cut -d' ' -f1)
printf 'v2\n' >"$TMP/new"
run fs_write alpha conf.sh "$base" "$TMP/new"
[ "$status" -eq 0 ]
[ "$(tail -n1 <<<"$output" | jq -r '.ok')" = true ]
[ "$(cat "$p/conf.sh")" = v2 ]
[ "$(stat -c %a "$p/conf.sh")" = 750 ]
[ "$(tail -n1 <<<"$output" | jq -r '.sha')" = "$(sha256sum "$p/conf.sh" | cut -d' ' -f1)" ]
# Someone (Claude) changed it since: the old base no longer matches.
printf 'v3 from me\n' >"$TMP/mine"
run fs_write alpha conf.sh "$base" "$TMP/mine"
[ "$(tail -n1 <<<"$output" | jq -r '[.ok, .conflict] | @tsv')" = "$(printf 'false\ttrue')" ]
[ "$(cat "$p/conf.sh")" = v2 ]
# No temp files left behind
[ -z "$(find "$p" -name '*.devdesk-*')" ]
# Empty files are fine
: >"$TMP/empty"
base=$(sha256sum "$p/conf.sh" | cut -d' ' -f1)
run fs_write alpha conf.sh "$base" "$TMP/empty"
[ "$(tail -n1 <<<"$output" | jq -r '.ok')" = true ]
[ ! -s "$p/conf.sh" ]
}
@test "fs-write creates new files (and folders) but never overwrites or escapes (#20)" {
p="$DEVDESK_PROJECTS_ROOT/alpha"
printf 'hello\n' >"$TMP/c"
run fs_write alpha docs/notes/new.md new "$TMP/c"
[ "$(tail -n1 <<<"$output" | jq -r '[.ok, .path] | @tsv')" = "$(printf 'true\tdocs/notes/new.md')" ]
[ "$(cat "$p/docs/notes/new.md")" = hello ]
run fs_write alpha docs/notes/new.md new "$TMP/c"
[ "$status" -ne 0 ]
[[ $output == *"already exists"* ]]
ln -s "$TMP/outside" "$p/out"
for bad in ../escape.txt /etc/x out/pwned.txt out/newdir/x.txt docs/ ""; do
run fs_write alpha "$bad" new "$TMP/c"
[ "$status" -ne 0 ]
done
[ ! -e "$TMP/outside/pwned.txt" ] && [ ! -e "$TMP/outside/newdir" ] && [ ! -e "$DEVDESK_PROJECTS_ROOT/escape.txt" ]
# A short stream fails without touching anything
run bash -c "printf aGVs | '$REPO/bin/devdesk' attach --fs-write alpha docs/notes/new.md 6 $(sha256sum "$p/docs/notes/new.md" | cut -d' ' -f1)"
[ "$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"* ]]
}
+30 -2
View File
@@ -4,7 +4,7 @@
// the first time the Files pane opens, so the page itself stays small.
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, highlightSpecialChars } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { syntaxHighlighting, HighlightStyle, StreamLanguage, bracketMatching, foldGutter, foldKeymap } from '@codemirror/language';
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { markdownLanguage } from '@codemirror/lang-markdown';
@@ -135,12 +135,40 @@ function baseExtensions(path, { readOnly = true, wrap = false } = {}) {
];
}
// Read-only (v1) view of a file.
// Read-only view of a file.
export function viewer(parent, { doc, path, wrap }) {
const view = new EditorView({ parent, state: EditorState.create({ doc, extensions: baseExtensions(path, { wrap }) }) });
return { view, destroy: () => view.destroy() };
}
// Editable file (devdesk#20): onChange(text) after every edit, onSave() on
// Ctrl/Cmd+S (which never reaches the browser or the terminal).
export function editor(parent, { doc, path, wrap, onChange, onSave }) {
const view = new EditorView({
parent,
state: EditorState.create({
doc,
extensions: [
keymap.of([{ key: 'Mod-s', preventDefault: true, run: () => { onSave?.(); return true; } }, indentWithTab]),
baseExtensions(path, { readOnly: false, wrap }),
EditorView.updateListener.of((update) => {
if (update.docChanged) onChange?.(update.state.doc.toString());
}),
],
}),
});
return {
view,
text: () => view.state.doc.toString(),
// Replace the whole document (reload from disk) keeping the cursor roughly where it was.
setText: (text) => {
const pos = Math.min(view.state.selection.main.head, text.length);
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text }, selection: { anchor: pos } });
},
destroy: () => view.destroy(),
};
}
// Unified diff of the HEAD version against the working copy.
export function diff(parent, { original, modified, path, wrap }) {
const view = new EditorView({
+1
View File
@@ -96,6 +96,7 @@
<button type="button" class="dd-icon-btn dd-explorer-toggle" title="Show / hide the explorer" aria-label="Toggle explorer">☰</button>
<div class="dd-tabs" role="tablist" aria-label="Open files"></div>
<span class="dd-files-actions">
<button type="button" class="dd-icon-btn dd-files-new" title="New file" aria-label="New file">+</button>
<button type="button" class="dd-icon-btn dd-files-find" title="Go to file (Ctrl+P)" aria-label="Go to file">⌕</button>
<button type="button" class="dd-icon-btn dd-files-refresh" title="Refresh" aria-label="Refresh">↻</button>
<button type="button" class="dd-icon-btn dd-files-close" title="Close (Esc)" aria-label="Close files">✕</button>
+45 -14
View File
@@ -209,11 +209,43 @@ export function uploadFile(session, file, onProgress) {
}
async function uploadNow(session, file, onProgress) {
const token = await fetchToken();
const name = (file.name || 'pasted-image.png').slice(-100);
const result = await streamIn(['--upload', session, name, String(file.size)], 'DEVDESK-UPLOAD-READY', file, onProgress);
if (!result.ok || !result.path) throw new Error('upload failed');
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
// changed since `base` (a sha256, or "new" to create).
export function writeFile(session, path, bytes, base) {
return limited(() => streamIn(['--fs-write', session, path, String(bytes.length), base], 'DEVDESK-WRITE-READY', new Blob([bytes])));
}
// Run a devdesk command that reads base64 from the terminal after printing
// `ready`: stream `blob` in slices (never all in memory), then resolve the JSON
// reply. Fails on 20 s of inactivity rather than a fixed total (devdesk#9).
async function streamIn(args, ready, blob, onProgress) {
const token = await fetchToken();
const slice = 3 * 16 * 1024; // multiple of 3: base64 of slices concatenates cleanly
return new Promise((resolve, reject) => {
const socket = new WebSocket(wsUrl(window.location, ['--upload', session, name, String(file.size)]), ['tty']);
const socket = new WebSocket(wsUrl(window.location, args), ['tty']);
socket.binaryType = 'arraybuffer';
const decoder = new TextDecoder();
let output = '';
@@ -224,15 +256,16 @@ async function uploadNow(session, file, onProgress) {
idle = setTimeout(() => socket.close(), 20000);
};
const send = async () => {
for (let offset = 0; offset < file.size; offset += slice) {
for (let offset = 0; offset < blob.size; offset += slice) {
while (socket.bufferedAmount > 1 << 20) await sleep(20);
if (socket.readyState !== WebSocket.OPEN) return;
const bytes = new Uint8Array(await file.slice(offset, offset + slice).arrayBuffer());
const bytes = new Uint8Array(await blob.slice(offset, offset + slice).arrayBuffer());
socket.send(encodeInput(toBase64(bytes)));
touch();
onProgress?.(Math.min(1, (offset + slice) / file.size));
onProgress?.(Math.min(1, (offset + slice) / blob.size));
}
};
const after = () => output.slice(output.indexOf(ready) + ready.length);
touch();
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
socket.onmessage = (event) => {
@@ -240,28 +273,26 @@ async function uploadNow(session, file, onProgress) {
const { type, payload } = decodeServer(event.data);
if (type !== Server.OUTPUT) return;
output += decoder.decode(payload, { stream: true });
if (!sending && output.includes('DEVDESK-UPLOAD-READY')) {
if (!sending && output.includes(ready)) {
sending = true;
send();
} else if (sending && isComplete(output.replace(/^[\s\S]*DEVDESK-UPLOAD-READY/, ''))) {
}
if (sending && isComplete(after())) {
socket.close(); // the reply is in; let devdesk exit (devdesk#35)
}
};
socket.onerror = () => {};
socket.onclose = () => {
clearTimeout(idle);
const text = stripAnsi(output.replace(/^[\s\S]*DEVDESK-UPLOAD-READY/, ''));
const text = stripAnsi(output.includes(ready) ? after() : output);
try {
const result = parseListOutput(text);
if (result.ok && result.path) {
resolve(result.path);
return;
}
resolve(parseListOutput(text));
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*/, '')));
reject(new Error((message || 'no reply').replace(/^devdesk:\s*/, '')));
};
});
}
+325 -13
View File
@@ -5,11 +5,11 @@
// see what Claude edited, and jump to any file by name. Read-only for now.
// Everything goes through `devdesk attach --fs-*` over ttyd.
import { runBlob, runCommand } from './connection.js';
import { runBlob, runCommand, writeFile } from './connection.js';
import { parseListOutput } from './protocol.js';
import { el } from './dom.js';
import { ago } from './sessions.js';
import { CommandPalette, toast } from './ui.js';
import { CommandPalette, openDialog, toast } from './ui.js';
// ---------------------------------------------------------------- editor bundle
@@ -164,6 +164,27 @@ function writeJson(key, value) {
}
}
// Unsaved edits survive a reload, a session switch or a crash (devdesk#20):
// each dirty buffer is mirrored to localStorage with the sha it was based on.
const draftKey = (session, path) => `devdesk.draft.${session}:${path}`;
function readDraft(session, path) {
try {
return JSON.parse(localStorage.getItem(draftKey(session, path)));
} catch {
return null;
}
}
function writeDraft(session, path, draft) {
try {
if (draft) localStorage.setItem(draftKey(session, path), JSON.stringify(draft));
else localStorage.removeItem(draftKey(session, path));
} catch {
// Quota or private mode: the edit just isn't backed up.
}
}
// ---------------------------------------------------------------- icons
// A two-character badge per file type (VS Code's icon idea, in the terminal style).
@@ -249,6 +270,7 @@ export class FilesPane {
root.querySelector('.dd-files-refresh').addEventListener('click', () => this.refresh());
root.querySelector('.dd-files-close').addEventListener('click', () => this.close());
root.querySelector('.dd-files-find').addEventListener('click', () => this.quickOpen.open());
root.querySelector('.dd-files-new').addEventListener('click', () => this.newFile());
root.querySelector('.dd-explorer-toggle').addEventListener('click', () => this.setExplorer(!this.explorerShown()));
this.explorer.addEventListener('keydown', (event) => this.treeKey(event));
root.addEventListener('keydown', (event) => {
@@ -256,8 +278,15 @@ export class FilesPane {
event.preventDefault();
this.quickOpen.open();
}
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.code === 'KeyS') {
event.preventDefault();
if (this.active) this.save(this.active);
}
});
this.wireExplorerResize();
// Leaving or reloading the page: write pending drafts now, not in 400 ms.
window.addEventListener('pagehide', () => this.flushDrafts());
window.addEventListener('beforeunload', () => this.flushDrafts());
// Narrow pane (phone, or dragged small): the explorer slides over the editor.
new ResizeObserver(() => root.classList.toggle('is-narrow', root.clientWidth < 680)).observe(root);
this.quickOpen = new CommandPalette(() => this.loadFileList(), {
@@ -343,6 +372,7 @@ export class FilesPane {
syncSession() {
const session = this.hooks.session();
if (session === this.session) return false;
this.flushDrafts();
for (const v of this.views.values()) v.editor?.destroy();
this.views.clear();
this.listings.clear();
@@ -689,7 +719,11 @@ export class FilesPane {
this.tabs[preview] = tab;
} else {
this.tabs.push(tab);
while (this.tabs.length > 12) this.closeTab(this.tabs.find((t) => t.key !== key).key, { render: false });
while (this.tabs.length > 12) {
const victim = this.tabs.find((t) => t.key !== key && !this.views.get(t.key)?.dirty);
if (!victim) break;
this.closeTab(victim.key, { render: false, force: true });
}
}
} else if (pin) {
tab.pinned = true;
@@ -716,7 +750,34 @@ export class FilesPane {
this.renderExplorer();
}
closeTab(key, { render = true } = {}) {
flushDrafts() {
for (const [key, view] of this.views) {
if (!view.editable || !view.draftTimer) continue;
clearTimeout(view.draftTimer);
view.draftTimer = 0;
const tab = this.tabs.find((t) => t.key === key);
if (tab) writeDraft(this.session, tab.path, view.dirty ? { text: view.editor.text(), base: view.base, at: Date.now() } : null);
}
}
hasDirty() {
return [...this.views.values()].some((v) => v.dirty);
}
async closeTab(key, { render = true, force = false } = {}) {
const view = this.views.get(key);
const tab = this.tabs.find((t) => t.key === key);
if (!force && view?.dirty && tab) {
const choice = await openDialog({
title: `Save changes to ${base(tab.path)}?`,
size: 'sm',
body: el('p', { class: 'dd-dialog-text' }, `${tab.path} has unsaved changes.`),
actions: [{ label: 'Cancel', value: null }, { label: 'Don\'t save', value: 'discard' }, { label: 'Save', primary: true }],
});
if (!choice) return;
if (choice === 'discard') writeDraft(this.session, tab.path, null);
else if (!(await this.save(key))) return;
}
const i = this.tabs.findIndex((t) => t.key === key);
if (i < 0) return;
this.tabs.splice(i, 1);
@@ -731,6 +792,7 @@ export class FilesPane {
}
dropView(key) {
clearTimeout(this.views.get(key)?.draftTimer);
this.views.get(key)?.editor?.destroy();
this.views.get(key)?.node.remove();
this.views.delete(key);
@@ -742,7 +804,7 @@ export class FilesPane {
const change = this.changeFor(tab.path);
const status = change ? statusOf(change) : null;
return el('div', {
class: `dd-tab${tab.key === this.active ? ' is-active' : ''}${tab.pinned ? '' : ' is-preview'}${status ? ` git-${status[2]}` : ''}`,
class: `dd-tab${tab.key === this.active ? ' is-active' : ''}${tab.pinned ? '' : ' is-preview'}${status ? ` git-${status[2]}` : ''}${this.views.get(tab.key)?.dirty ? ' is-dirty' : ''}`,
role: 'tab', 'aria-selected': String(tab.key === this.active), tabindex: tab.key === this.active ? '0' : '-1',
title: `${tab.path}${tab.mode === 'diff' ? ' (diff against HEAD)' : ''}${tab.pinned ? '' : ' · preview: the next file replaces it; double-click to keep'}`,
onclick: () => this.activate(tab.key),
@@ -758,12 +820,12 @@ export class FilesPane {
el('span', { class: 'dd-tab-name' }, name),
tab.mode === 'diff' ? el('span', { class: 'dd-tab-tag' }, 'diff') : null,
el('button', {
type: 'button', class: 'dd-tab-close', 'aria-label': `Close ${name}`, title: 'Close',
type: 'button', class: 'dd-tab-close', 'aria-label': `Close ${name}`, title: this.views.get(tab.key)?.dirty ? 'Unsaved changes: close…' : 'Close',
onclick: (event) => {
event.stopPropagation();
this.closeTab(tab.key);
},
}, '×'));
}, this.views.get(tab.key)?.dirty ? '●' : '×'));
});
this.tabStrip.replaceChildren(...nodes);
this.tabStrip.querySelector('.is-active')?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
@@ -851,6 +913,10 @@ export class FilesPane {
const [{ blob, original }, editor] = await Promise.all([this.fetchTab(tab), loadEditor().catch((error) => ({ error }))]);
if (session !== this.session || this.views.get(key) !== view) return;
if (blob.header.sha && blob.header.sha === view.header?.sha && tab.mode !== 'diff') return;
if (view.dirty) {
if (blob.header.sha !== view.base) this.changedOnDisk(key);
return;
}
const scroller = view.node.querySelector('.cm-scroller');
const top = scroller?.scrollTop ?? 0;
view.editor?.destroy();
@@ -863,7 +929,7 @@ export class FilesPane {
}
}
bar(tab, header) {
bar(tab, header, view) {
const change = this.changeFor(tab.path);
const meta = header ? [
size(header.size ?? 0),
@@ -876,6 +942,10 @@ export class FilesPane {
const button = (label, run, extra = {}) => el('button', { type: 'button', class: 'dd-viewer-btn', onclick: run, ...extra }, label);
const switchMode = (mode) => this.openFile(tab.path, { mode, pin: true });
const actions = [];
if (view?.editable) {
actions.push(button('Save', () => this.save(tab.key), { class: 'dd-viewer-btn is-primary dd-dirty-only', title: 'Save (Ctrl+S)' }));
actions.push(button('Revert', () => this.revert(tab.key), { class: 'dd-viewer-btn dd-dirty-only', title: 'Discard your changes' }));
}
if (tab.mode !== 'diff' && isMarkdown(tab.path)) actions.push(button(tab.mode === 'preview' ? 'Source' : 'Preview', () => switchMode(tab.mode === 'preview' ? 'view' : 'preview')));
if (change && change.x !== '?' && change.y !== '?' && this.changes?.git) {
actions.push(button(tab.mode === 'diff' ? 'File' : 'Diff', () => switchMode(tab.mode === 'diff' ? (isMarkdown(tab.path) ? 'preview' : 'view') : 'diff')));
@@ -897,7 +967,8 @@ export class FilesPane {
return el('div', { class: 'dd-viewer-bar' },
el('span', { class: 'dd-viewer-where', title: [tab.path, meta].filter(Boolean).join('\n') },
el('span', { class: 'dd-viewer-path' }, tab.path),
meta ? el('span', { class: 'dd-viewer-meta' }, ` · ${meta}`) : null),
meta ? el('span', { class: 'dd-viewer-meta' }, ` · ${meta}`) : null,
view?.editable ? el('span', { class: 'dd-unsaved dd-dirty-only' }, ' · unsaved') : null),
el('span', { class: 'dd-viewer-actions' }, ...actions));
}
@@ -917,20 +988,261 @@ export class FilesPane {
content.append(el('pre', { class: 'dd-plain' }, text(base64, header.utf8)));
} else if (tab.mode === 'preview') {
content.classList.add('is-preview');
// Unsaved edits in the Source tab show in the preview too.
const source = this.views.get(`view:${tab.path}`);
const md = source?.dirty ? source.editor.text() : text(base64);
const frame = el('iframe', {
class: 'dd-md-frame', title: `Preview of ${tab.path}`,
sandbox: 'allow-popups allow-popups-to-escape-sandbox', srcdoc: previewDocument(editor.markdown(text(base64))),
sandbox: 'allow-popups allow-popups-to-escape-sandbox', srcdoc: previewDocument(editor.markdown(md)),
});
content.append(frame);
inlineImages(editor.markdown(text(base64)), this.session, tab.path).then((html) => {
inlineImages(editor.markdown(md), this.session, tab.path).then((html) => {
if (frame.isConnected) frame.srcdoc = previewDocument(html);
});
} else if (tab.mode === 'diff') {
view.editor = editor.diff(content, { original: text(original?.base64), modified: text(base64), path: tab.path, wrap: this.wrap });
} else if (header.utf8 === false) {
// Not UTF-8: shown as Latin-1, read-only (saving would change the bytes).
view.editor = editor.viewer(content, { doc: text(base64, false), path: tab.path, wrap: this.wrap });
} else {
view.editor = editor.viewer(content, { doc: text(base64, header.utf8), path: tab.path, wrap: this.wrap });
const disk = text(base64);
const draft = readDraft(this.session, tab.path);
let doc = disk;
let banner = null;
if (draft && draft.text !== disk) {
if (draft.base === header.sha) {
doc = draft.text;
toast.info(`Restored your unsaved changes to ${tab.path}`);
} else {
banner = draft;
}
}
view.editable = true;
view.saved = disk;
view.base = header.sha;
view.editor = editor.editor(content, {
doc, path: tab.path, wrap: this.wrap,
onChange: (now) => this.edited(tab.key, now),
onSave: () => this.save(tab.key),
});
view.dirty = doc !== disk;
view.node.classList.toggle('is-dirty', view.dirty);
view.node.replaceChildren(this.bar(tab, header, view), content);
if (banner) this.oldDraft(tab.key, banner);
if (view.dirty) this.renderTabs();
return;
}
view.node.replaceChildren(this.bar(tab, header, view), content);
}
// ------------------------------------------------------------ editing (devdesk#20)
edited(key, now) {
const view = this.views.get(key);
const tab = this.tabs.find((t) => t.key === key);
if (!view || !tab) return;
const dirty = now !== view.saved;
if (dirty && !tab.pinned) {
tab.pinned = true; // editing keeps a preview tab
this.saveState();
}
clearTimeout(view.draftTimer);
view.draftTimer = setTimeout(() => writeDraft(this.session, tab.path, dirty ? { text: now, base: view.base, at: Date.now() } : null), 400);
if (dirty !== view.dirty) {
view.dirty = dirty;
view.node.classList.toggle('is-dirty', dirty);
this.renderTabs();
}
}
// Resolves true when saved. A conflict (the file changed on disk since it was
// opened) asks before overwriting.
async save(key, { base: forced } = {}) {
const view = this.views.get(key);
const tab = this.tabs.find((t) => t.key === key);
if (!view?.editable || !tab || view.saving) return false;
if (!view.dirty && !forced) return true;
const now = view.editor.text();
view.saving = true;
try {
const result = await writeFile(this.session, tab.path, new TextEncoder().encode(now), forced ?? view.base ?? 'new');
if (result.conflict) return this.conflict(key, result.sha);
if (!result.ok) throw new Error('not saved');
view.saved = now;
view.base = result.sha;
view.header = { ...view.header, sha: result.sha, size: result.size, mtime: result.mtime };
clearTimeout(view.draftTimer);
writeDraft(this.session, tab.path, null);
this.edited(key, view.editor.text());
view.node.querySelector('.dd-viewer-banner')?.remove();
toast.success(`Saved ${tab.path}`);
this.loadChanges();
this.fileList = null;
return true;
} catch (error) {
toast.error(`Could not save ${tab.path}: ${error.message}`);
return false;
} finally {
view.saving = false;
}
}
revert(key) {
const view = this.views.get(key);
const tab = this.tabs.find((t) => t.key === key);
if (!view?.editable || !tab) return;
clearTimeout(view.draftTimer);
writeDraft(this.session, tab.path, null);
view.editor.setText(view.saved);
view.node.querySelector('.dd-viewer-banner')?.remove();
}
async diskText(path) {
const { header, base64 } = await runBlob(['--fs-read', this.session, path]);
return { sha: header.sha, text: new TextDecoder().decode(decodeBase64(base64 || '')) };
}
async conflict(key, diskSha) {
const tab = this.tabs.find((t) => t.key === key);
const choice = await openDialog({
title: `${base(tab.path)} changed on disk`,
size: 'sm',
body: el('p', { class: 'dd-dialog-text' }, `Someone (probably Claude) changed ${tab.path} after you opened it. Saving now would overwrite that.`),
actions: [{ label: 'Cancel', value: null }, { label: 'Compare', value: 'compare' }, { label: 'Overwrite', primary: true, danger: true }],
danger: true,
});
if (choice === 'compare') return this.compare(key);
if (choice) return this.save(key, { base: diskSha });
return false;
}
// Disk version vs yours, side by side in a unified diff; pick one.
async compare(key) {
const view = this.views.get(key);
const tab = this.tabs.find((t) => t.key === key);
if (!view || !tab) return false;
let disk;
let editor;
try {
[disk, editor] = await Promise.all([this.diskText(tab.path), loadEditor()]);
} catch (error) {
toast.error(error.message);
return false;
}
const holder = el('div', { class: 'dd-compare' });
let diffView = null;
const choice = await openDialog({
title: `${tab.path}: disk (red) vs yours (green)`,
className: 'is-wide',
body: holder,
onOpen: () => {
diffView = editor.diff(holder, { original: disk.text, modified: view.editor.text(), path: tab.path, wrap: this.wrap });
return holder.querySelector('.cm-content');
},
actions: [{ label: 'Close', value: null }, { label: 'Take the disk version', value: 'disk' }, { label: 'Save mine', primary: true }],
});
diffView?.destroy();
if (choice === 'disk') {
view.saved = disk.text;
view.base = disk.sha;
view.header = { ...view.header, sha: disk.sha };
view.editor.setText(disk.text);
writeDraft(this.session, tab.path, null);
view.node.querySelector('.dd-viewer-banner')?.remove();
return false;
}
if (choice) return this.save(key, { base: disk.sha });
return false;
}
banner(key, text, actions) {
const view = this.views.get(key);
if (!view) return;
view.node.querySelector('.dd-viewer-banner')?.remove();
const node = el('div', { class: 'dd-viewer-banner', role: 'alert' }, el('span', {}, text),
...actions.map(([label, run]) => el('button', { type: 'button', class: 'dd-viewer-btn', onclick: run }, label)));
view.node.querySelector('.dd-viewer-bar')?.after(node);
}
// Claude changed a file you're editing: nothing is replaced until you choose.
changedOnDisk(key) {
this.banner(key, 'This file changed on disk while you were editing it.', [
['Compare', () => this.compare(key)],
['Reload (discard mine)', () => {
this.revert(key);
this.dropView(key);
this.renderEditor();
}],
['Keep mine', () => this.views.get(key)?.node.querySelector('.dd-viewer-banner')?.remove()],
]);
}
// Unsaved changes from an earlier visit, but the file changed since.
oldDraft(key, draft) {
const view = this.views.get(key);
const tab = this.tabs.find((t) => t.key === key);
const age = ago(Math.floor(draft.at / 1000));
this.banner(key, `You have unsaved changes from ${age === 'now' ? 'a moment ago' : `${age} ago`}, but the file changed on disk since.`, [
['Compare', async () => {
view.editor.setText(draft.text);
await this.compare(key);
}],
['Restore mine', () => {
view.editor.setText(draft.text);
view.node.querySelector('.dd-viewer-banner')?.remove();
}],
['Discard', () => {
writeDraft(this.session, tab.path, null);
view.node.querySelector('.dd-viewer-banner')?.remove();
}],
]);
}
// New file in the folder you're looking at (folders are created as needed).
async newFile() {
if (!this.session) return;
const activePath = this.tabs.find((t) => t.key === this.active)?.path;
const row = this.treeRows?.[this.focusIndex];
const folder = row?.kind === 'dir' ? row.path : parent(row?.path ?? activePath ?? '');
const input = el('input', { class: 'lt-input', value: folder ? `${folder}/` : '', spellcheck: 'false', autocomplete: 'off', 'aria-describedby': 'dd-newfile-hint' });
const hint = el('p', { class: 'dd-dialog-hint', id: 'dd-newfile-hint' }, `Relative to ${this.listings.get('')?.root ?? 'the session folder'}. Missing folders are created.`);
const path = await openDialog({
title: 'New file',
size: 'sm',
body: el('div', { class: 'dd-dialog-fields' }, el('label', { class: 'dd-field' }, el('span', {}, 'Path'), input), hint),
actions: [{ label: 'Cancel' }, { label: 'Create', primary: true }],
onOpen: () => {
input.setSelectionRange(input.value.length, input.value.length);
return input;
},
submit: () => {
const value = input.value.trim().replace(/^\/+/, '');
if (!value || value.endsWith('/') || value.split('/').some((part) => part === '..' || part === '.')) {
hint.textContent = 'Type a file name (no .. or trailing /).';
hint.classList.add('is-error');
return null;
}
return value;
},
});
if (!path) return;
try {
const result = await writeFile(this.session, path, new Uint8Array(0), 'new');
if (!result.ok) throw new Error('not created');
// Show it: re-list every folder above the new file and open them.
const parts = result.path.split('/').slice(0, -1);
for (let i = 0; i <= parts.length; i += 1) {
const dir = parts.slice(0, i).join('/');
if (dir) this.expanded.add(dir);
this.listDir(dir);
}
this.saveState();
this.fileList = null;
this.openFile(result.path, { mode: 'view', pin: true });
toast.success(`Created ${result.path}`);
} catch (error) {
toast.error(`Could not create ${path}: ${error.message}`);
}
view.node.replaceChildren(this.bar(tab, header), content);
}
// Theme switch: the editor follows CSS variables; previews need a re-render.
+121 -8
View File
@@ -5,14 +5,17 @@
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;
const MAX_AGE = 7 * 86400 * 1000;
function load() {
try {
const list = JSON.parse(localStorage.getItem(KEY) || '[]');
return Array.isArray(list) ? list : [];
return Array.isArray(list) ? list.filter((e) => Date.now() - e.at < MAX_AGE) : [];
} catch {
return [];
}
@@ -45,6 +48,7 @@ export class Inbox {
this.events = load();
this.waiting = [];
this.alerts = [];
this.known = null; // session names that exist, once the list has loaded
this.list = panel.querySelector('.lt-notif-panel-list');
panel.querySelector('.lt-notif-panel-clear').addEventListener('click', () => this.markAllRead());
window.addEventListener('storage', (event) => {
@@ -63,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();
}
@@ -78,9 +84,10 @@ export class Inbox {
}
// Live state: other sessions waiting on you, firing alerts.
setLive({ waiting, alerts }) {
setLive({ waiting, alerts, sessions }) {
if (waiting) this.waiting = waiting;
if (alerts) this.alerts = alerts;
if (sessions) this.known = new Set(sessions);
this.render();
}
@@ -91,7 +98,7 @@ export class Inbox {
}
unread() {
return this.events.filter((e) => !e.read && e.kind !== 'waiting').length;
return this.events.filter((e) => !e.read && e.kind !== 'waiting' && !(this.known && e.session && !e.url && !this.known.has(e.session))).length;
}
render() {
@@ -110,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();
@@ -133,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) {
@@ -155,15 +259,24 @@ export class Inbox {
nodes.push(el('div', { class: 'dd-notif-section' }, 'Recent'));
for (const e of recent.slice(0, 12)) {
const kind = KINDS[e.kind] ?? { tone: 'cyan' };
nodes.push(item({
dot: e.read ? 'read' : kind.tone, unread: !e.read, title: e.text, meta: when(e.at),
// 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);
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(() => {
this.events = this.events.map((x) => (x.id === e.id ? { ...x, read: true } : x));
save(this.events);
if (e.url) window.open(e.url, '_blank', 'noopener');
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.'));
+47 -12
View File
@@ -58,6 +58,8 @@ const ui = {
};
const urlSession = sessionFromSearch(window.location.search);
// Notification links (Matrix) add existing=1: open, never re-create (devdesk#38).
const urlExisting = new URLSearchParams(window.location.search).get('existing') === '1';
const state = {
session: urlSession, // null = picker
conn: null,
@@ -169,7 +171,11 @@ function recordTransitions(sessions) {
}
function renderPanelsBadge() {
inbox?.setLive({ waiting: waitingOthers, alerts: lastAlerts?.alerts ?? [] });
inbox?.setLive({
waiting: waitingOthers,
alerts: lastAlerts?.alerts ?? [],
sessions: sessionsPanel?.data ? sessionsPanel.data.sessions.map((x) => x.name) : null,
});
}
function onSessionData({ fetched = false } = {}) {
@@ -316,14 +322,19 @@ function updateUrl(session) {
const url = new URL(window.location.href);
if (session) url.searchParams.set('arg', session);
else url.searchParams.delete('arg');
url.searchParams.delete('existing');
if (url.href !== window.location.href) window.history.replaceState(null, '', url);
}
// ---------------------------------------------------------------- connection
// reconnect: automatic re-attach after a drop. It must not re-create a session
// that was ended or renamed meanwhile, so it asks for --existing (devdesk#4).
function connect(session, { reconnect = false } = {}) {
// existing: go to a session that should already exist (reconnects, the
// switcher, inbox entries, notification links). It must not re-create one that
// was ended or renamed meanwhile, so it asks for --existing (devdesk#4, #38).
// Creating is explicit: a typed name, the New dialog, the picker, recents.
function connect(session, { existing = false, reconnect = false } = {}) {
const existingOnly = Boolean(session) && (existing || reconnect);
state.existingOnly = existingOnly;
clearTimeout(state.retryTimer);
state.conn?.dispose();
state.session = session;
@@ -339,7 +350,7 @@ function connect(session, { reconnect = false } = {}) {
term.reset();
const conn = new TtydConnection({
arg: session && reconnect ? ['--existing', session] : session,
arg: existingOnly ? ['--existing', session] : session,
size: () => ({ cols: term.cols, rows: term.rows }),
onOpen: () => {
state.openedAt = Date.now();
@@ -373,6 +384,25 @@ function onClosed(code) {
// few seconds of opening means the session could not start (the reason is
// printed in the terminal), so don't retry forever.
const failedFast = code !== 1000 && Date.now() - state.openedAt < 4000;
if (failedFast && state.existingOnly) {
// Most likely ended or renamed since the link was made: say so, and only
// start it again when asked.
const name = state.session;
sessionsPanel?.reload().then(() => {
const known = sessionsPanel.data?.sessions?.some((x) => x.name === name);
if (known) {
scheduleReconnect();
return;
}
setWsState('disconnected', 'ended');
showOverlay({
title: `${name} has ended`,
text: `The session "${name}" no longer exists: it was ended or renamed. Its conversation is still in Claude's history.`,
actions: [pickerAction, { label: `Start ${name} again`, run: () => connect(name) }],
});
});
return;
}
if (failedFast) {
showOverlay({
title: 'Session did not start',
@@ -515,7 +545,7 @@ function renderSwitcher() {
return el('button', {
type: 'button', role: 'menuitem',
class: `dd-menu-item dd-switch-item is-${status.key}${s.name === state.session ? ' is-current' : ''}`,
title: s.dir || '', onclick: choose(() => connect(s.name)),
title: s.dir || '', onclick: choose(() => connect(s.name, { existing: true })),
},
el('span', { class: `lt-dot ${status.dot}` }),
el('span', { class: 'dd-switch-name' }, s.name),
@@ -591,7 +621,7 @@ function buildCommands() {
const status = sessionStatus(s);
add({
id: `switch:${s.name}`, group: 'Sessions', label: `Switch to ${s.name}`, hint: status.label, keywords: s.dir,
icon: status.key === 'waiting' ? '!' : s.running ? '●' : '○', run: () => connect(s.name),
icon: status.key === 'waiting' ? '!' : s.running ? '●' : '○', run: () => connect(s.name, { existing: true }),
});
}
add({ id: 'new', group: 'Sessions', icon: '+', label: 'New session…', keywords: 'create open project', run: () => sessionsPanel.newDialog() });
@@ -682,7 +712,9 @@ const KEY_HELP = [
]],
['DevDesk', [
['Ctrl+⇧ P', 'Command palette'],
['Ctrl+⇧ E', 'Files: browse, preview, diff (Esc closes)'],
['Ctrl+⇧ E', 'Files: browse, preview, diff, edit (Esc closes)'],
['Ctrl+P', 'Files: go to file'],
['Ctrl+S', 'Files: save the open file'],
['Ctrl+⇧ C / V', 'Copy selection / paste'],
['⇧ drag', 'Select text in the browser'],
['Drop or paste a file', 'Upload it and attach it for Claude'],
@@ -866,7 +898,9 @@ function wireChrome() {
badge: $('inbox-badge'),
panel: $('inbox-panel'),
hooks: {
openSession: (name) => connect(name),
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' });
@@ -921,7 +955,7 @@ function wireChrome() {
// Ctrl+W closes the tab before the terminal sees it; ask first.
window.addEventListener('beforeunload', (event) => {
if (state.conn?.isOpen && state.session) {
if ((state.conn?.isOpen && state.session) || files?.hasDirty()) {
event.preventDefault();
event.returnValue = '';
}
@@ -957,7 +991,7 @@ function wireChrome() {
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshRepo();
}, 60 * 1000);
ui.attention.addEventListener('click', () => {
if (ui.attention.dataset.target) connect(ui.attention.dataset.target);
if (ui.attention.dataset.target) connect(ui.attention.dataset.target, { existing: true });
});
}
@@ -1057,7 +1091,8 @@ async function boot() {
wireUploads();
refreshAlerts();
await sessionsPanel.refresh();
connect(urlSession ?? defaultSession(sessionsPanel.data));
if (urlSession) connect(urlSession, { existing: urlExisting });
else connect(defaultSession(sessionsPanel.data), { existing: true });
}
boot();
+2 -2
View File
@@ -52,7 +52,7 @@ export const dialogOpen = () => openDialogs > 0 || !!document.querySelector('.lt
// A .lt-modal dialog. `body` is a node; `actions` are { label, value, primary,
// danger }. Resolves with the chosen action's value (or `submit()`'s value when
// a form is submitted), or null when dismissed (✕, Esc, backdrop).
export function openDialog({ title, body, actions = [], danger = false, size = '', onOpen, submit }) {
export function openDialog({ title, body, actions = [], danger = false, size = '', className = '', onOpen, submit }) {
return new Promise((resolve) => {
const opener = document.activeElement;
let done = false;
@@ -81,7 +81,7 @@ export function openDialog({ title, body, actions = [], danger = false, size = '
if (button.type === 'submit') button.setAttribute('form', titleId);
}
form.id = titleId;
const overlay = el('div', { class: 'lt-modal-overlay is-open dd-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': `${titleId}-title` },
const overlay = el('div', { class: `lt-modal-overlay is-open dd-dialog${className ? ` ${className}` : ''}`, role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': `${titleId}-title` },
el('div', { class: `lt-modal${size ? ` lt-modal-${size}` : ''}` },
el('div', { class: `lt-modal-header${danger ? ' lt-modal-header--danger' : ''}` },
el('h2', { class: 'lt-modal-title', id: `${titleId}-title` }, title),
+52
View File
@@ -924,3 +924,55 @@ html[data-theme="light"] .dd-files.is-narrow .dd-explorer { box-shadow: 8px 0 24
.dd-tree-row { min-height: 34px; }
.dd-tab-close { opacity: 0.7; width: 28px; height: 28px; }
}
/* ---- editing (devdesk#20) ------------------------------------------------------------- */
.dd-tab-view:not(.is-dirty) .dd-dirty-only { display: none; }
.dd-unsaved { color: var(--accent-amber); }
.dd-tab.is-dirty .dd-tab-close { opacity: 1; color: var(--accent-amber); }
.dd-tab.is-dirty .dd-tab-close:hover { color: var(--accent-red); }
.dd-viewer-btn.is-primary { border-color: var(--accent-orange); color: var(--accent-orange); }
.dd-viewer-btn.is-primary:hover { background: var(--accent-orange-dim); }
.dd-viewer-banner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
padding: 5px 10px;
border-bottom: 1px solid var(--accent-amber-dim);
background: var(--accent-amber-dim);
color: var(--accent-amber);
font-size: 0.72rem;
}
.dd-dialog.is-wide .lt-modal { width: min(1100px, 94vw); max-height: 88vh; }
.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; }