Compare commits

..
4 Commits
Author SHA1 Message Date
jaredandClaude Opus 5.5 390ce61f5b feat: inbox bell for waiting sessions, alerts and missed events
build-deb / Lint, test, package (push) Successful in 3m7s
build-deb / Deploy to CT 126 (push) Successful in 2s
build-deb / Notify on failure (push) Skipped
Closes #36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:51:01 -04:00
jaredandClaude Opus 5.5 6e109aec36 test: keep script's stdin open in the linger test (CI has no stdin)
build-deb / Lint, test, package (push) Successful in 2m10s
build-deb / Deploy to CT 126 (push) Successful in 2s
build-deb / Notify on failure (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:36:50 -04:00
jaredandClaude Opus 5.5 05057f695d feat: v0.6.0 light mode, command palette, dialogs, toasts
build-deb / Lint, test, package (push) Failing after 1m59s
build-deb / Deploy to CT 126 (push) Skipped
build-deb / Notify on failure (push) Successful in 5s
- Light mode with the house ☀/◐ toggle and lt_theme preference; light
  terminal palette; Claude runs with dark-ansi so its colours follow the
  palette; tmux and the statusLine use ANSI colours.
- Command palette (⌕, Ctrl+Shift+P) with session, terminal, view, link and
  help commands.
- .lt-modal dialogs for new/rename/end and a keyboard shortcuts sheet.
- .lt-toast results for actions, uploads, copy and CI.
- ttyd dropped the reply of fast one-shot commands; replies now wait for the
  browser to hang up, and the client hangs up as soon as it has one.
- Sessions panel re-fetches after an action instead of reusing a stale poll.

Closes #31
Closes #32
Closes #33
Closes #34
Closes #35

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:32:23 -04:00
jaredandClaude Opus 5.5 0d86f89957 fix: list a just-created session immediately
build-deb / Lint, test, package (push) Successful in 1m55s
build-deb / Deploy to CT 126 (push) Successful in 2s
build-deb / Notify on failure (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:42:03 -04:00
20 changed files with 1267 additions and 141 deletions
+5 -2
View File
@@ -36,7 +36,8 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
- **Conversation tracking:** a Claude `SessionStart` hook records each session's conversation ID in `/var/lib/devdesk/registry.json`. After a reboot the session resumes *that* conversation, even when other Claude instances, such as code-server, use the same directory.
- **Session status:** Claude hooks (`devdesk-state-hook`) mark each session **working**, **waiting on you** (with Claude's message) or **idle**.
- 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. With the panels closed, the **Panels** button shows a count of waiting sessions and Grafana alerts.
- 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.
- **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,6 +47,7 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
- 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.
- **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.
- **SSH:** `devdesk attach <name>`, `devdesk new <name> [project]`, `devdesk rename <old> <new>`, `devdesk end <name>`, `devdesk ls`, `devdesk pick`, `devdesk repo <name>`, `devdesk alerts`.
@@ -59,8 +61,9 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
| Ctrl-b d | Detach (the session keeps running) |
| Shift+drag | Native browser selection (plain drag goes to tmux copy-mode, which copies via OSC 52) |
| Ctrl+Shift+C / V | Copy selection / paste |
| Ctrl+Shift+P | Command palette (also the ⌕ button) |
Text size, desktop notifications (with a mute) and fullscreen are in the **⋯** menu. In Chromium, fullscreen also captures Ctrl+W/Ctrl+T, which browsers otherwise keep for themselves.
The **☀/◐** button switches light and dark mode (saved per browser, shared with the other web_template apps' `lt_theme` preference). Text size, desktop notifications (with a mute), the keyboard shortcuts sheet and fullscreen are in the **⋯** menu; everything is also in the command palette. In Chromium, fullscreen also captures Ctrl+W/Ctrl+T, which browsers otherwise keep for themselves.
## Configuration
- **Put your changes in `/etc/devdesk/env.local`.** It's read after the packaged `env` and never shipped, so `apt upgrade` never asks about it. Example: `DEVDESK_REMOTE_CONTROL=1`.
+5
View File
@@ -296,20 +296,24 @@ cmd_attach() {
--list)
[ $# -eq 1 ] || dd_die "--list takes no arguments"
cmd_list
dd_linger
return
;;
--repo)
shift
cmd_repo "$@"
dd_linger
return
;;
--alerts)
cmd_alerts
dd_linger
return
;;
--upload)
shift
cmd_upload "$@"
dd_linger
return
;;
--new | --rename | --end)
@@ -317,6 +321,7 @@ cmd_attach() {
shift
"cmd_$action" "$@"
ok_json
dd_linger
return
;;
--existing)
+43
View File
@@ -1,3 +1,46 @@
devdesk (0.6.1) bookworm; urgency=medium
* Inbox bell (#36): sessions waiting on you, firing alerts, and what
happened while you were away (a background session finished or started
waiting, CI passed or failed). Click to jump there; shared by all tabs.
Replaces the count on the Panels button.
* Phone header: theme in the ⋯ menu, Panels as an icon, so the bell fits.
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 08 Oct 2026 00:50:56 -0400
devdesk (0.6.0) bookworm; urgency=medium
* Light mode, like the other web_template apps (#31): the ☀/◐ button in the
header, shared lt_theme preference (OS setting until you choose), applied
before first paint and synced across tabs. The terminal has a light
palette; Claude runs with its dark-ansi theme so its colours come from
that palette, and the light palette maps bright white to ink, so Claude's
text, scrollback included, stays readable in both modes. tmux and the
statusLine use ANSI colours so they follow the theme too. Sessions pick
up Claude's theme the next time Claude starts in them.
* Command palette (#32): ⌕ button or Ctrl+Shift+P. Switch, create, rename
and end sessions, Claude/Shell window, scroll back, detach, theme, panels,
text size, fullscreen, notifications, copy link, open the repo / PRs / CI
run, show alerts, keyboard help.
* Dialogs instead of browser prompt/confirm for rename, end and new
session (name check inline, project folder picker), and a keyboard
shortcuts sheet in the ⋯ menu, the palette and the footer (#33).
* Toasts for results: created, renamed, ended, uploads, link copied, CI
finished, errors (#34).
* Fix: renames, ends and new sessions often reported "no response" although
they worked; ttyd dropped the reply of commands that exit right after
printing. Replies now wait for the browser to hang up (#35).
* Fix: the Sessions panel showed stale names right after a rename or end.
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 08 Oct 2026 00:32:11 -0400
devdesk (0.5.1) bookworm; urgency=medium
* A session created by opening its URL shows up in the Sessions panel and
the switcher right away instead of at the next poll.
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 23:42:01 -0400
devdesk (0.5.0) bookworm; urgency=medium
* Security (from the 2026-10-07 review):
+11
View File
@@ -43,8 +43,19 @@ DEVDESK_CONTEXT_DIR="$DEVDESK_STATE_DIR/context"
DEVDESK_STATUS_DIR="$DEVDESK_STATE_DIR/state"
DEVDESK_NAME_RE='^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$'
# Under ttyd, a command that exits right after printing sometimes loses its last
# output: ttyd sees the exit and closes the WebSocket before reading the pty
# (devdesk#35). One-shot replies and errors therefore wait, at most
# DEVDESK_LINGER seconds, for the browser to hang up once it has the answer
# (ttyd then sends SIGHUP). devdesk-serve sets DEVDESK_VIA_TTYD=1.
dd_linger() {
[ "${DEVDESK_VIA_TTYD:-}" = 1 ] && [ -t 0 ] || return 0
read -r -t "${DEVDESK_LINGER:-1}" _ 2>/dev/null || true
}
dd_die() {
printf 'devdesk: %s\n' "$*" >&2
dd_linger
exit 1
}
+3
View File
@@ -22,4 +22,7 @@ if [ -n "${DEVDESK_BIND:-}" ]; then
args+=(--interface "$DEVDESK_BIND")
fi
# Tells `devdesk attach` it talks to a browser through ttyd (see dd_linger).
export DEVDESK_VIA_TTYD=1
exec "${DEVDESK_TTYD:-/usr/lib/devdesk/bin/ttyd}" "${args[@]}" "${DEVDESK_BIN:-/usr/bin/devdesk}" attach
+8 -7
View File
@@ -38,12 +38,13 @@ if [[ $session =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]]; then
fi
fi
# Footer line, in TDS colours: session · model · context % · cost
# Footer line: session · model · context % · cost. ANSI colour numbers, not RGB,
# so the browser's dark or light palette applies (devdesk#31).
jq -rM --arg s "$session" '
def c($rgb; $t): "\u001b[38;2;\($rgb)m\($t)\u001b[0m";
def c($sgr; $t): "\u001b[\($sgr)m\($t)\u001b[0m";
def pct: if . == null then "—" else "\(. | floor)%" end;
[ (if $s != "" then c("255;107;0"; "◈ \($s)") else empty end),
c("0;212;255"; .model.display_name // "Claude"),
c("127;163;191"; "ctx \(.context_window.used_percentage | pct)"),
c("62;96;122"; "$\((.cost.total_cost_usd // 0) * 100 | round / 100)")
] | join(c("62;96;122"; " · "))' <<<"$input" 2>/dev/null || true
[ (if $s != "" then c("33"; "◈ \($s)") else empty end),
c("36"; .model.display_name // "Claude"),
c("37"; "ctx \(.context_window.used_percentage | pct)"),
c("37"; "$\((.cost.total_cost_usd // 0) * 100 | round / 100)")
] | join(c("90"; " · "))' <<<"$input" 2>/dev/null || true
+1
View File
@@ -1,5 +1,6 @@
{
"preferredNotifChannel": "terminal_bell",
"theme": "dark-ansi",
"hooks": {
"SessionStart": [
{
+12 -9
View File
@@ -24,18 +24,21 @@ set -g set-titles-string "devdesk:#S|#T"
# --- every new session gets a spare shell next to Claude ---------------------
set-hook -g session-created "new-window -d -n shell"
# --- LotusGuild TDS colours ---------------------------------------------------
# --- colours ---------------------------------------------------------------------
# ANSI colour names, not hex: the browser maps them through its dark or light
# palette, so the status bar follows DevDesk's theme (devdesk#31). black is the
# bar, white muted text, cyan the accent, yellow the session name.
set -g status-position bottom
set -g status-style "bg=#060c14,fg=#7fa3bf"
set -g status-left "#[fg=#FF6B00,bold] #S #[fg=#3e607a,nobold]│ "
set -g status-style "bg=black,fg=white"
set -g status-left "#[fg=yellow,bold] #S #[fg=brightblack,nobold]│ "
set -g status-left-length 40
set -g status-right ""
set -g window-status-format " #I:#W "
set -g window-status-current-format "#[fg=#030508,bg=#00D4FF,bold] #I:#W "
set -g window-status-bell-style "fg=#FFB300,bold"
set -g message-style "bg=#0d1520,fg=#00D4FF"
set -g mode-style "bg=#00D4FF,fg=#030508"
set -g pane-border-style "fg=#0d1520"
set -g pane-active-border-style "fg=#00D4FF"
set -g window-status-current-format "#[fg=cyan,reverse,bold] #I:#W "
set -g window-status-bell-style "fg=yellow,bold"
set -g message-style "bg=black,fg=cyan"
set -g mode-style "fg=cyan,reverse"
set -g pane-border-style "fg=brightblack"
set -g pane-active-border-style "fg=cyan"
source-file -q /etc/devdesk/tmux.local.conf
+19
View File
@@ -327,6 +327,25 @@ posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; }
kill "$RECEIVER"
}
@test "under ttyd, one-shot replies wait for the browser to hang up (#35)" {
start_server
# No tty (CLI, tests): no waiting.
start=$(date +%s%N)
DEVDESK_VIA_TTYD=1 DEVDESK_LINGER=2 devdesk attach --list >/dev/null
[ $(( ($(date +%s%N) - start) / 1000000 )) -lt 1500 ]
# On a tty, as under ttyd: the reply is printed, then it waits. script's
# stdin stays open (like a browser that hasn't hung up); a closed stdin
# would hand the command EOF at once.
start=$(date +%s%N)
run bash -c "sleep 2 | script -qec \"DEVDESK_VIA_TTYD=1 DEVDESK_LINGER=0.6 '$REPO/bin/devdesk' attach --list\" /dev/null"
elapsed=$(( ($(date +%s%N) - start) / 1000000 ))
[[ $output == *'"sessions"'* ]]
[ "$elapsed" -ge 500 ]
# Errors are printed before the wait, too.
run bash -c "sleep 1 | script -qec \"DEVDESK_VIA_TTYD=1 DEVDESK_LINGER=0.3 '$REPO/bin/devdesk' attach --rename nosuch x\" /dev/null"
[[ $output == *"no session named"* ]]
}
@test "alerts reports unconfigured and unreachable Grafana cleanly" {
run env DEVDESK_GRAFANA_URL= devdesk alerts
[ "$status" -eq 0 ]
+26 -2
View File
@@ -3,11 +3,20 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
<meta name="color-scheme" content="dark">
<meta name="color-scheme" content="dark light">
<meta name="theme-color" content="#030508">
<meta name="generator" content="devdesk">
<title>DevDesk</title>
<link rel="icon" id="favicon" href="__FAVICON__">
<script>
// Theme before first paint (same key as web_template's lt.theme), no dark flash.
try {
var t = localStorage.getItem('lt_theme');
if (t !== 'light' && t !== 'dark') t = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', t);
document.documentElement.style.colorScheme = t;
} catch (e) { /* storage blocked: stay dark */ }
</script>
<!--STYLES-->
</head>
<body class="dd-app">
@@ -31,6 +40,16 @@
<span class="lt-ws-status dd-ws" id="ws-status" data-state="connecting" role="status">
<span class="lt-dot"></span><span id="ws-label">connecting</span>
</span>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm dd-cmd-btn" id="cmd-toggle" title="Command palette (Ctrl+Shift+P)" aria-label="Open command palette">⌕<span class="dd-cmd-kbd">&nbsp;⌃⇧P</span></button>
<button type="button" class="lt-theme-btn" id="theme-toggle" aria-label="Switch to light mode" title="Switch to light mode">☀</button>
<div class="lt-notif-dropdown-wrap dd-inbox">
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm lt-notif-bell-btn" id="inbox-toggle" aria-haspopup="dialog" aria-expanded="false" aria-controls="inbox-panel" aria-label="Inbox" title="Inbox">🔔</button>
<span class="lt-notif-badge" id="inbox-badge" data-count="0" aria-hidden="true"></span>
<div class="lt-notif-panel dd-inbox-panel" id="inbox-panel" role="dialog" aria-label="Inbox" aria-hidden="true">
<div class="lt-notif-panel-header"><span>Inbox</span><button type="button" class="lt-notif-panel-clear">Mark all read</button></div>
<div class="lt-notif-panel-list"></div>
</div>
</div>
<div class="dd-more">
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm dd-more-toggle" id="more-toggle" aria-haspopup="menu" aria-expanded="false" title="Settings" aria-label="Settings">⋯</button>
<div class="dd-menu dd-more-menu" id="more-menu" role="menu" hidden>
@@ -42,12 +61,14 @@
<button type="button" class="dd-icon-btn" id="font-up" aria-label="Larger text">A+</button>
</span>
</div>
<button type="button" class="dd-menu-item" id="theme-menu" role="menuitem">Light mode</button>
<button type="button" class="dd-menu-item" id="notify-toggle" role="menuitem">Desktop notifications</button>
<button type="button" class="dd-menu-item" id="help-open" role="menuitem">Keyboard shortcuts</button>
<button type="button" class="dd-menu-item" id="fullscreen-toggle" role="menuitem" title="In Chromium, fullscreen also captures Ctrl+W/Ctrl+T for the terminal">Fullscreen</button>
<div class="dd-menu-foot" id="version">devdesk</div>
</div>
</div>
<button type="button" class="lt-btn lt-btn-sm dd-panels" id="sidebar-toggle" aria-controls="sidebar" aria-expanded="false">Panels<span class="dd-badge" id="panels-badge" hidden></span></button>
<button type="button" class="lt-btn lt-btn-sm dd-panels" id="sidebar-toggle" aria-controls="sidebar" aria-expanded="false"><span class="dd-panels-label">Panels</span><span class="dd-panels-icon" aria-hidden="true">▤</span></button>
</div>
</header>
@@ -128,6 +149,9 @@
<span class="lt-footer-hint"><span class="lt-footer-key">⇧drag</span>select</span>
<span class="lt-footer-sep">│</span>
<span class="lt-footer-hint"><span class="lt-footer-key">drop / paste</span>attach files</span>
<span class="lt-footer-sep">│</span>
<span class="lt-footer-hint"><span class="lt-footer-key">^⇧P</span>commands</span>
<button type="button" class="dd-footer-help" id="footer-help">all keys</button>
</div>
<div class="dd-keybar" id="keybar" role="toolbar" aria-label="Terminal keys" hidden>
<button type="button" data-keys="esc">Esc</button>
+1 -1
View File
@@ -2,7 +2,7 @@
// each with an "Ask Claude" button that types an investigation prompt into the
// current session (without pressing Enter).
import { el } from './sessions.js';
import { el } from './dom.js';
const SEVERITY = {
critical: 'lt-chip-critical',
+32 -6
View File
@@ -121,6 +121,19 @@ export function runCommand(args, timeoutMs = 8000) {
return limited(() => runCommandNow(args, timeoutMs));
}
// A full JSON reply, or devdesk's one-line error.
function isComplete(text) {
const clean = stripAnsi(text);
if (/^devdesk: .*\n/m.test(clean)) return true;
if (!/\}\s*$/.test(clean)) return false;
try {
parseListOutput(clean);
return true;
} catch {
return false;
}
}
async function runCommandNow(args, timeoutMs) {
const token = await fetchToken();
return new Promise((resolve, reject) => {
@@ -132,15 +145,26 @@ async function runCommandNow(args, timeoutMs) {
reject(new Error('devdesk did not answer'));
}, timeoutMs);
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
socket.onmessage = (event) => {
const { type, payload } = decodeServer(event.data);
if (type === Server.OUTPUT) chunks.push(payload.slice());
};
socket.onerror = () => {};
socket.onclose = () => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(new TextDecoder().decode(concat(chunks)));
};
socket.onmessage = (event) => {
const { type, payload } = decodeServer(event.data);
if (type !== Server.OUTPUT) return;
chunks.push(payload.slice());
// Hang up as soon as the answer is complete: devdesk waits for that
// before exiting, so ttyd can't drop its last output (devdesk#35).
if (isComplete(new TextDecoder().decode(concat(chunks)))) {
finish();
socket.close();
}
};
socket.onerror = () => {};
socket.onclose = finish;
});
}
@@ -211,6 +235,8 @@ async function uploadNow(session, file, onProgress) {
if (!sending && output.includes('DEVDESK-UPLOAD-READY')) {
sending = true;
send();
} else if (sending && isComplete(output.replace(/^[\s\S]*DEVDESK-UPLOAD-READY/, ''))) {
socket.close(); // the reply is in; let devdesk exit (devdesk#35)
}
};
socket.onerror = () => {};
+13
View File
@@ -0,0 +1,13 @@
// Tiny DOM builder: el('a', { class, href, onclick }, ...children). Attributes
// that are null/undefined/false are skipped; on* keys become event listeners.
export function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
if (value === undefined || value === null || value === false) continue;
if (key === 'class') node.className = value;
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
else node.setAttribute(key, value === true ? '' : value);
}
node.append(...children.filter((c) => c !== null && c !== undefined));
return node;
}
+172
View File
@@ -0,0 +1,172 @@
// The bell (house .lt-notif-* pattern): what needs you right now (waiting
// sessions, firing Grafana alerts) plus recent events you may have missed
// (a background session finished or started waiting, CI passed or failed).
// Events live in localStorage, so every tab shows the same inbox.
import { el } from './dom.js';
import { ago } from './sessions.js';
const KEY = 'devdesk.inbox';
const MAX = 30;
function load() {
try {
const list = JSON.parse(localStorage.getItem(KEY) || '[]');
return Array.isArray(list) ? list : [];
} catch {
return [];
}
}
function save(events) {
try {
localStorage.setItem(KEY, JSON.stringify(events.slice(0, MAX)));
} catch {
// Private mode: the inbox lasts as long as the tab.
}
}
const when = (ms) => {
const a = ago(Math.floor(ms / 1000));
return a === 'now' ? 'just now' : `${a} ago`;
};
const KINDS = {
waiting: { icon: '!', tone: 'red' },
finished: { icon: '✓', tone: 'green' },
'ci-pass': { icon: '✓', tone: 'green' },
'ci-fail': { icon: '✗', tone: 'red' },
};
export class Inbox {
// hooks: openSession(name), showAlerts()
constructor({ button, badge, panel, hooks }) {
Object.assign(this, { button, badge, panel, hooks });
this.events = load();
this.waiting = [];
this.alerts = [];
this.list = panel.querySelector('.lt-notif-panel-list');
panel.querySelector('.lt-notif-panel-clear').addEventListener('click', () => this.markAllRead());
window.addEventListener('storage', (event) => {
if (event.key === KEY) {
this.events = load();
this.render();
}
});
}
get isOpen() {
return !this.panel.hasAttribute('aria-hidden');
}
setOpen(open) {
if (open) this.panel.removeAttribute('aria-hidden');
else this.panel.setAttribute('aria-hidden', 'true');
this.button.setAttribute('aria-expanded', String(open));
if (open) this.render();
}
// Something happened. The same kind for the same session within a minute is
// one event (several tabs, quick flaps).
record({ kind, session, text, url }) {
const now = Date.now();
if (this.events.some((e) => e.kind === kind && e.session === session && now - e.at < 60000)) return;
this.events.unshift({ id: `${now}-${Math.random().toString(36).slice(2, 7)}`, kind, session, text, url, at: now, read: false });
this.events = this.events.slice(0, MAX);
save(this.events);
this.render();
}
// Live state: other sessions waiting on you, firing alerts.
setLive({ waiting, alerts }) {
if (waiting) this.waiting = waiting;
if (alerts) this.alerts = alerts;
this.render();
}
markAllRead() {
this.events = this.events.map((e) => ({ ...e, read: true }));
save(this.events);
this.render();
}
unread() {
return this.events.filter((e) => !e.read && e.kind !== 'waiting').length;
}
render() {
const count = this.waiting.length + this.alerts.length + this.unread();
const urgent = this.waiting.length > 0 || this.alerts.some((a) => a.severity === 'critical');
this.badge.textContent = count ? String(count) : '';
this.badge.dataset.count = String(count);
this.badge.classList.toggle('is-calm', count > 0 && !urgent);
this.button.title = count
? [
this.waiting.length && `${this.waiting.length} session(s) need you`,
this.alerts.length && `${this.alerts.length} alert(s) firing`,
this.unread() && `${this.unread()} new event(s)`,
].filter(Boolean).join(' · ')
: 'Inbox: nothing new';
if (this.isOpen) this.renderPanel();
}
renderPanel() {
const close = (run) => () => {
this.setOpen(false);
run();
};
const item = ({ dot, title, meta, unread, onclick }) => el('div', {
class: `lt-notif-item${unread ? ' lt-notif-item--unread' : ''}`, role: 'button', tabindex: '0', onclick,
onkeydown: (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onclick();
}
},
},
el('span', { class: `lt-notif-dot dd-notif-${dot}` }),
el('div', { class: 'lt-notif-item-body' },
el('div', { class: 'lt-notif-item-title' }, title),
meta ? el('div', { class: 'lt-notif-item-time' }, meta) : null));
const nodes = [];
if (this.waiting.length) {
nodes.push(el('div', { class: 'dd-notif-section' }, 'Needs you'));
for (const s of this.waiting) {
nodes.push(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)),
}));
}
}
if (this.alerts.length) {
nodes.push(el('div', { class: 'dd-notif-section' }, 'Ops alerts'));
for (const a of this.alerts.slice(0, 5)) {
nodes.push(item({
dot: a.severity === 'critical' ? 'red' : 'amber', title: `${a.severity ? `${a.severity}: ` : ''}${a.name}`,
meta: [a.state, a.summary].filter(Boolean).join(' · '), onclick: close(() => this.hooks.showAlerts()),
}));
}
if (this.alerts.length > 5) nodes.push(el('div', { class: 'dd-notif-more' }, `+${this.alerts.length - 5} more in the Ops Alerts panel`));
}
const recent = this.events.filter((e) => e.kind !== 'waiting' || !this.waiting.some((s) => s.name === e.session));
if (recent.length) {
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),
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 (e.session) this.hooks.openSession(e.session);
}),
}));
}
}
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.'));
this.list.replaceChildren(...nodes);
}
}
+249 -34
View File
@@ -1,14 +1,18 @@
/* global __DEVDESK_VERSION__ */
import { createTerminal, loadFonts } from './term.js';
import { THEMES, createTerminal, loadFonts } from './term.js';
import { getTheme, initTheme, onThemeChange, toggleTheme } from './theme.js';
import {
TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError,
} from './connection.js';
import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.js';
import { SessionsPanel, el, recentNames, rememberSession, sessionStatus } from './sessions.js';
import { SessionsPanel, recentNames, rememberSession, sessionStatus } from './sessions.js';
import { renderAlerts } from './alerts.js';
import { renderRepo } from './repo.js';
import { bindAskClaude } from './widgets.js';
import { heardWithin, onShared, share } from './shared.js';
import { CommandPalette, dialogOpen, openDialog, toast } from './ui.js';
import { Inbox } from './inbox.js';
import { el } from './dom.js';
import * as notify from './notify.js';
const $ = (id) => document.getElementById(id);
@@ -49,7 +53,6 @@ const ui = {
moreMenu: $('more-menu'),
notifyButton: $('notify-toggle'),
sidebarButton: $('sidebar-toggle'),
panelsBadge: $('panels-badge'),
backdrop: $('backdrop'),
};
@@ -70,6 +73,9 @@ const isNarrow = () => !window.matchMedia('(min-width: 1100px)').matches;
let term;
let fit;
let sessionsPanel;
let palette;
let inbox;
let lastRepo = null;
let lastAlerts = null;
let waitingOthers = [];
let paneTitleRaw = '';
@@ -125,6 +131,7 @@ function renderSessionChip() {
// waiting (devdesk#15), and the Panels badge. Only the tab that fetched the list
// notifies, so several open tabs don't ring once each.
let prevWaiting = new Set();
const prevStatus = new Map();
function renderAttention(fetched) {
const data = sessionsPanel?.data;
waitingOthers = (data?.sessions ?? []).filter((s) => s.name !== state.session && sessionStatus(s).key === 'waiting');
@@ -132,6 +139,7 @@ function renderAttention(fetched) {
if (fetched && !prevWaiting.has(s.name)) notify.ring(s.name, s.status?.message || 'Claude needs your input');
}
prevWaiting = new Set(waitingOthers.map((s) => s.name));
if (fetched) recordTransitions(data?.sessions ?? []);
ui.attention.classList.toggle('is-active', waitingOthers.length > 0);
ui.attention.textContent = waitingOthers.length === 1 ? `${waitingOthers[0].name} needs you` : `${waitingOthers.length} need you`;
ui.attention.title = waitingOthers.map((s) => `${s.name}: ${s.status?.message || 'waiting'}`).join('\n');
@@ -139,17 +147,27 @@ function renderAttention(fetched) {
renderPanelsBadge();
}
// Inbox events for what happened while you were elsewhere: a session (other than
// the one you're looking at) finished a turn or started waiting. Only the tab
// that fetched the list records them; the inbox is shared via localStorage.
function recordTransitions(sessions) {
for (const s of sessions) {
const key = sessionStatus(s).key;
const prev = prevStatus.get(s.name);
prevStatus.set(s.name, key);
if (!prev || prev === key) continue;
const away = s.name !== state.session || document.hidden;
if (!away) continue;
if (key === 'waiting') {
inbox.record({ kind: 'waiting', session: s.name, text: `${s.name} needs you: ${s.status?.message || 'Claude is waiting for your input'}` });
} else if (prev === 'working' && key === 'idle') {
inbox.record({ kind: 'finished', session: s.name, text: `${s.name} finished` });
}
}
}
function renderPanelsBadge() {
const alerts = lastAlerts?.alerts ?? [];
const count = waitingOthers.length + alerts.length;
const urgent = waitingOthers.length > 0 || alerts.some((a) => a.severity === 'critical');
ui.panelsBadge.hidden = count === 0 || ui.body.classList.contains('dd-sidebar-open');
ui.panelsBadge.textContent = String(count);
ui.panelsBadge.classList.toggle('is-urgent', urgent);
ui.panelsBadge.title = [
waitingOthers.length ? `${waitingOthers.length} session(s) need you` : '',
alerts.length ? `${alerts.length} Grafana alert(s)` : '',
].filter(Boolean).join(' · ');
inbox?.setLive({ waiting: waitingOthers, alerts: lastAlerts?.alerts ?? [] });
}
function onSessionData({ fetched = false } = {}) {
@@ -193,7 +211,7 @@ function uploadMessage(text, isError = false, sticky = false) {
async function attachFiles(files) {
if (!files.length) return;
if (!state.session || !state.conn?.isOpen) {
uploadMessage('Open a session first: files are attached to the current session.', true);
toast.warning('Open a session first: files are attached to the current session.');
return;
}
const session = state.session;
@@ -204,15 +222,17 @@ async function attachFiles(files) {
uploadMessage(`Uploading ${file.name || 'image'}… ${Math.round(p * 100)}%`, false, true));
paths.push(path);
} catch (error) {
uploadMessage(`${file.name || 'file'}: ${error.message}`, true);
uploadUi.hidden = true;
toast.error(`${file.name || 'file'}: ${error.message}`);
return;
}
}
uploadUi.hidden = true;
if (session !== state.session) return;
// A pasted image path makes Claude attach the image itself.
term.paste(`${paths.join(' ')} `);
term.focus();
uploadMessage(paths.length === 1 ? `Attached ${paths[0]}` : `Attached ${paths.length} files`);
toast.success(paths.length === 1 ? `Attached ${paths[0].split('/').pop()}` : `Attached ${paths.length} files`);
}
function wireUploads() {
@@ -257,6 +277,7 @@ let repoBusy = false;
async function refreshRepo() {
const session = state.session;
if (!session) {
lastRepo = null;
renderRepo($('widget-repo'), null);
return;
}
@@ -266,10 +287,14 @@ async function refreshRepo() {
const repo = await fetchRepo(session);
if (session !== state.session) return;
renderRepo($('widget-repo'), repo);
lastRepo = repo;
const key = `${session}@${repo.sha}`;
const ci = repo.ci?.total ? repo.ci.state : '';
if (key === lastCi.key && lastCi.state === 'pending' && ['success', 'failure', 'error'].includes(ci)) {
notify.ring(session, `CI ${ci === 'success' ? 'passed' : 'failed'} on ${repo.branch} @ ${repo.sha}`);
const text = `CI ${ci === 'success' ? 'passed' : 'failed'} on ${repo.branch} @ ${repo.sha}`;
notify.ring(session, text);
toast[ci === 'success' ? 'success' : 'error'](text);
inbox.record({ kind: ci === 'success' ? 'ci-pass' : 'ci-fail', session, text: `${session}: ${text}`, url: repo.ci?.url });
}
lastCi = { key, state: ci };
} catch {
@@ -318,6 +343,7 @@ function connect(session, { reconnect = false } = {}) {
state.attempts = 0;
setWsState('connected', 'live');
if (session) rememberSession(session);
refreshSoon(800); // opening ?arg=<new name> just created this session
refreshRepo();
fit.fit();
term.focus();
@@ -468,6 +494,7 @@ function closeMenus() {
menu.hidden = true;
button.setAttribute('aria-expanded', 'false');
}
if (inbox?.isOpen) inbox.setOpen(false);
}
// Session switcher on the session chip (devdesk#16).
@@ -496,10 +523,7 @@ function renderSwitcher() {
el('div', { class: 'dd-menu-sep' }),
el('button', {
type: 'button', role: 'menuitem', class: 'dd-menu-item',
onclick: choose(() => {
setSidebar(true);
sessionsPanel.toggleForm(true);
}),
onclick: choose(() => sessionsPanel.newDialog()),
}, '+ New session…'),
el('button', { type: 'button', role: 'menuitem', class: 'dd-menu-item', onclick: choose(() => connect(null)) }, 'Open picker'),
);
@@ -508,6 +532,7 @@ function renderSwitcher() {
// ---------------------------------------------------------------- chrome controls
function setFontSize(size) {
if (!Number.isFinite(size)) return;
const clamped = Math.min(24, Math.max(10, size));
term.options.fontSize = clamped;
store.set('fontSize', clamped);
@@ -515,6 +540,157 @@ function setFontSize(size) {
fit.fit();
}
async function toggleNotifications() {
const current = notify.notificationsState();
if (current === 'default') {
const result = await notify.requestNotifications();
if (result === 'granted') toast.success('Desktop notifications on');
else if (result === 'denied') toast.warning('The browser blocked notifications for this site');
} else if (current === 'granted') {
notify.setMuted(!notify.isMuted());
toast.info(notify.isMuted() ? 'Desktop notifications muted in this browser' : 'Desktop notifications unmuted');
} else {
toast.warning(current === 'denied' ? 'Notifications are blocked in the browser\'s site settings' : 'This browser has no notifications');
}
}
// ---------------------------------------------------------------- palette & help
const sendKeys = (keys) => state.conn?.sendInput(keys);
async function copyLink() {
const url = new URL(window.location.href);
url.search = state.session ? `?arg=${encodeURIComponent(state.session)}` : '';
try {
await navigator.clipboard.writeText(url.href);
toast.success('Link copied');
} catch {
toast.error('Could not copy: the browser refused clipboard access');
}
}
const openUrl = (url) => window.open(url, '_blank', 'noopener');
// Commands for the palette (devdesk#32), rebuilt on every open.
function buildCommands() {
const sessions = [...(sessionsPanel?.data?.sessions ?? [])].sort((a, b) =>
(b.running - a.running) || ((b.activity || 0) - (a.activity || 0)) || a.name.localeCompare(b.name));
const current = sessions.find((s) => s.name === state.session);
const panelsOpen = ui.body.classList.contains('dd-sidebar-open');
const notifyState = notify.notificationsState();
const commands = [];
const add = (command) => commands.push(command);
for (const s of sessions) {
if (s.name === state.session) continue;
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),
});
}
add({ id: 'new', group: 'Sessions', icon: '+', label: 'New session…', keywords: 'create open project', run: () => sessionsPanel.newDialog() });
if (state.session) {
add({ id: 'rename', group: 'Sessions', icon: '✎', label: `Rename ${state.session}…`, run: () => sessionsPanel.rename(state.session) });
add({
id: 'end', group: 'Sessions', icon: '×', label: `End ${state.session}…`, keywords: 'close kill stop',
run: () => sessionsPanel.end(current ?? { name: state.session, running: true }),
});
add({ id: 'copy-link', group: 'Sessions', icon: '⧉', label: 'Copy link to this session', keywords: 'url share', run: copyLink });
}
add({ id: 'picker', group: 'Sessions', icon: '≡', label: 'Open the session picker', keywords: 'fzf list', run: () => connect(null) });
if (state.session && state.conn?.isOpen) {
add({ id: 'win-claude', group: 'Terminal', icon: '0', label: 'Claude window', kbd: '^b 0', run: () => sendKeys('\x020') });
add({ id: 'win-shell', group: 'Terminal', icon: '1', label: 'Shell window', kbd: '^b 1', run: () => sendKeys('\x021') });
add({ id: 'scroll', group: 'Terminal', icon: '⇞', label: 'Scroll back (tmux copy mode, q to leave)', kbd: '^b [', keywords: 'history scrollback', run: () => sendKeys('\x02[') });
add({ id: 'detach', group: 'Terminal', icon: '⏏', label: 'Detach (the session keeps running)', kbd: '^b d', run: () => sendKeys('\x02d') });
}
add({
id: 'theme', group: 'View', icon: getTheme() === 'light' ? '◐' : '☀', keywords: 'theme light dark mode colour color',
label: getTheme() === 'light' ? 'Switch to dark mode' : 'Switch to light mode', run: toggleTheme,
});
add({ id: 'panels', group: 'View', icon: '▤', label: panelsOpen ? 'Hide panels' : 'Show panels', keywords: 'sidebar', run: () => setSidebar(!panelsOpen) });
add({ id: 'font-up', group: 'View', icon: 'A+', label: 'Larger text', keywords: 'font zoom', run: () => setFontSize(term.options.fontSize + 1) });
add({ id: 'font-down', group: 'View', icon: 'A−', label: 'Smaller text', keywords: 'font zoom', run: () => setFontSize(term.options.fontSize - 1) });
add({ id: 'font-reset', group: 'View', icon: 'A', label: 'Reset text size', keywords: 'font zoom', run: () => setFontSize(14) });
add({ id: 'fullscreen', group: 'View', icon: '⛶', label: document.fullscreenElement ? 'Exit fullscreen' : 'Fullscreen', run: toggleFullscreen });
if (notifyState !== 'unsupported') {
add({
id: 'notify', group: 'View', icon: '🔔', keywords: 'notifications alerts bell',
label: { default: 'Enable desktop notifications', denied: 'Desktop notifications (blocked)', granted: notify.isMuted() ? 'Unmute desktop notifications' : 'Mute desktop notifications' }[notifyState],
run: () => toggleNotifications().then(renderNotifyButton),
});
}
if (lastRepo?.url) {
add({ id: 'open-repo', group: 'Links', icon: '↗', label: `Open ${lastRepo.repo} in Gitea`, run: () => openUrl(lastRepo.url) });
add({ id: 'open-prs', group: 'Links', icon: '↗', label: 'Open pull requests', run: () => openUrl(`${lastRepo.url}/pulls`) });
if (lastRepo.ci?.url) add({ id: 'open-ci', group: 'Links', icon: '↗', label: `Open CI run (${lastRepo.ci.state})`, run: () => openUrl(lastRepo.ci.url) });
}
if (lastAlerts?.alerts?.length) {
add({
id: 'alerts', group: 'Links', icon: '!', label: `Show ${lastAlerts.alerts.length} ops alert(s)`, keywords: 'grafana',
run: () => {
setSidebar(true);
$('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' });
},
});
}
add({ id: 'help', group: 'Help', icon: '?', label: 'Keyboard shortcuts', keywords: 'keys help', run: showHelp });
// After a command, typing goes back to the terminal unless it opened a dialog.
return commands.map((c) => ({
...c,
run: () => {
c.run();
if (!dialogOpen()) term.focus();
},
}));
}
const KEY_HELP = [
['Claude', [
['⇧ Enter', 'New line in the prompt'],
['Esc', 'Interrupt Claude'],
['⇧ Tab', 'Cycle mode (auto-accept, plan…)'],
['↑ / ↓', 'Previous / next prompt'],
['Ctrl+C', 'Cancel; twice to quit (the menu offers resume)'],
]],
['Windows & sessions (tmux)', [
['Ctrl-b 0 / 1', 'Claude window / shell window'],
['Ctrl-b s', 'Switch session (the URL follows)'],
['Ctrl-b [', 'Scroll back; q to leave'],
['Ctrl-b d', 'Detach (the session keeps running)'],
]],
['DevDesk', [
['Ctrl+⇧ P', 'Command palette'],
['Ctrl+⇧ C / V', 'Copy selection / paste'],
['⇧ drag', 'Select text in the browser'],
['Drop or paste a file', 'Upload it and attach it for Claude'],
['Ctrl+W', 'Asks before closing; Chromium fullscreen keeps it for the terminal'],
]],
];
function showHelp() {
const body = el('div', { class: 'dd-keys' }, ...KEY_HELP.map(([title, rows]) => el('section', {},
el('h3', { class: 'dd-keys-title' }, title),
el('dl', { class: 'dd-keys-list' }, ...rows.flatMap(([keys, what]) => [el('dt', {}, el('kbd', {}, keys)), el('dd', {}, what)])))));
if (touch) body.append(el('p', { class: 'dd-dialog-hint' }, 'On a phone, the key bar under the terminal sends Esc, Tab, ⇧Tab, Ctrl+C, arrows, Enter and the window switches.'));
openDialog({ title: 'Keyboard shortcuts', body, actions: [{ label: 'Close', primary: true }] }).then(() => term.focus());
}
async function toggleFullscreen() {
if (document.fullscreenElement) {
await document.exitFullscreen();
return;
}
await document.documentElement.requestFullscreen();
// Chromium: capture Ctrl+W/Ctrl+T/Esc etc. for the terminal while fullscreen.
navigator.keyboard?.lock?.().catch(() => {});
}
function renderNotifyButton() {
const current = notify.notificationsState();
const labels = {
@@ -577,21 +753,56 @@ function wireChrome() {
$('font-down').addEventListener('click', () => setFontSize(term.options.fontSize - 1));
$('font-up').addEventListener('click', () => setFontSize(term.options.fontSize + 1));
ui.notifyButton.addEventListener('click', async () => {
if (notify.notificationsState() === 'default') await notify.requestNotifications();
else if (notify.notificationsState() === 'granted') notify.setMuted(!notify.isMuted());
await toggleNotifications();
renderNotifyButton();
});
$('fullscreen-toggle').addEventListener('click', async () => {
$('fullscreen-toggle').addEventListener('click', () => {
closeMenus();
if (document.fullscreenElement) {
await document.exitFullscreen();
return;
}
await document.documentElement.requestFullscreen();
// Chromium: capture Ctrl+W/Ctrl+T/Esc etc. for the terminal while fullscreen.
navigator.keyboard?.lock?.().catch(() => {});
toggleFullscreen();
});
wireMenu(ui.moreToggle, ui.moreMenu, renderNotifyButton);
$('help-open').addEventListener('click', () => {
closeMenus();
showHelp();
});
$('footer-help').addEventListener('click', showHelp);
palette = new CommandPalette(buildCommands);
$('cmd-toggle').addEventListener('click', () => palette.open());
// Ctrl+Shift+P (⌘⇧P): Ctrl+K belongs to the terminal (kill-line in Claude and bash).
document.addEventListener('keydown', (event) => {
if ((event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyP') {
event.preventDefault();
event.stopPropagation();
if (palette.isOpen) palette.close();
else if (!dialogOpen()) palette.open();
}
}, true);
wireMenu(ui.moreToggle, ui.moreMenu, () => {
renderNotifyButton();
$('theme-menu').textContent = getTheme() === 'light' ? 'Dark mode' : 'Light mode';
});
$('theme-menu').addEventListener('click', () => {
closeMenus();
toggleTheme();
});
inbox = new Inbox({
button: $('inbox-toggle'),
badge: $('inbox-badge'),
panel: $('inbox-panel'),
hooks: {
openSession: (name) => connect(name),
showAlerts: () => {
setSidebar(true);
$('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' });
},
},
});
$('inbox-toggle').addEventListener('click', (event) => {
event.stopPropagation();
const open = !inbox.isOpen;
closeMenus();
inbox.setOpen(open);
});
$('inbox-panel').addEventListener('click', (event) => event.stopPropagation());
wireMenu(ui.sessionChip, ui.switcher, () => {
renderSwitcher();
sessionsPanel.refresh();
@@ -607,8 +818,8 @@ function wireChrome() {
// Esc closes menus and the narrow-screen drawer before the terminal sees it.
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
const menuOpen = menus.some(({ menu }) => !menu.hidden);
if (event.key !== 'Escape' || dialogOpen()) return;
const menuOpen = menus.some(({ menu }) => !menu.hidden) || inbox.isOpen;
const drawerOpen = isNarrow() && ui.body.classList.contains('dd-sidebar-open');
if (!menuOpen && !drawerOpen) return;
event.preventDefault();
@@ -679,6 +890,7 @@ function defaultSession(data) {
// ---------------------------------------------------------------- boot
async function boot() {
initTheme();
$('version').textContent = `devdesk ${__DEVDESK_VERSION__}`;
const fontSize = Math.min(24, Math.max(10, Number(store.get('fontSize', 14)) || 14));
$('font-size').textContent = String(fontSize);
@@ -686,8 +898,11 @@ async function boot() {
({ term, fit } = createTerminal($('terminal'), {
fontSize,
theme: getTheme(),
onShiftEnter: () => state.conn?.sendInput('\n'),
}));
onThemeChange((theme) => { term.options.theme = THEMES[theme]; });
$('theme-toggle').addEventListener('click', toggleTheme);
term.onData((data) => state.conn?.sendInput(data));
term.onBinary((data) => state.conn?.sendBinary(data));
term.onResize(({ cols, rows }) => state.conn?.resize(cols, rows));
+105 -40
View File
@@ -4,6 +4,10 @@
import { fetchSessionList, sessionAction } from './connection.js';
import { slugify } from './protocol.js';
import { share } from './shared.js';
import { el } from './dom.js';
import { confirmDialog, openDialog, toast } from './ui.js';
export { el };
const RECENTS_KEY = 'devdesk.recents';
const MAX_RECENTS = 12;
@@ -51,17 +55,6 @@ export function ago(epochSeconds) {
return `${Math.floor(s / 86400)}d`;
}
export function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
if (value === undefined || value === null || value === false) continue;
if (key === 'class') node.className = value;
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
else node.setAttribute(key, value === true ? '' : value);
}
node.append(...children.filter((c) => c !== null && c !== undefined));
return node;
}
// What Claude is doing in a session, from the devdesk-state-hook snapshot.
// Returns { key, label, dot } for display; key is one of
@@ -114,6 +107,12 @@ export class SessionsPanel {
return this.pending;
}
// After an action: a poll already in flight may predate it, so fetch again.
async reload() {
await this.pending;
return this.refresh();
}
async load() {
let fetched = false;
try {
@@ -135,14 +134,13 @@ export class SessionsPanel {
this.hooks.onData?.(this.data, { fetched: false });
}
async act(label, action, ...args) {
this.setStatus(`${label}…`);
// Side-channel action; failures become an error toast (devdesk#34).
async act(action, ...args) {
try {
await sessionAction(action, ...args);
this.setStatus('');
return true;
} catch (err) {
this.setStatus(err.message, true);
toast.error(err.message);
return false;
}
}
@@ -151,43 +149,116 @@ export class SessionsPanel {
const project = this.select.value;
const name = slugify(this.nameInput.value) || project;
if (!name) {
this.setStatus('Type a session name or pick a project.', true);
toast.warning('Type a session name or pick a project.');
this.nameInput.focus();
return;
}
// With a project the session starts in that folder; without one, in the default folder.
if (project && !(await this.act('creating', 'new', name, project))) return;
if (!(await this.create(name, project))) return;
this.nameInput.value = '';
this.select.value = '';
this.toggleForm(false);
}
// With a project the session starts in that folder; without one, opening it
// creates it in the default folder (or its own project folder, by name).
async create(name, project) {
if (project && !(await this.act('new', name, project))) return false;
this.hooks.onOpen(name);
return true;
}
projectOptions(select) {
const projects = this.data?.projects ?? [];
const chosen = select.value;
select.replaceChildren(
el('option', { value: '' }, `No project (${this.data?.root ?? 'default folder'})`),
...projects.filter((p) => p.name !== this.data?.default)
.map((p) => el('option', { value: p.name, title: p.dir }, p.name)));
select.value = chosen;
}
// "+ New session…" from the switcher or the command palette (devdesk#33).
async newDialog() {
const name = el('input', { class: 'lt-input', maxlength: '64', placeholder: 'e.g. pbs-upgrade', spellcheck: 'false', autocomplete: 'off', 'aria-describedby': 'dd-new-hint' });
const project = el('select', { class: 'lt-select' });
this.projectOptions(project);
const hint = el('p', { class: 'dd-dialog-hint', id: 'dd-new-hint' }, 'Letters, numbers, - and _. Any name works; pick a project to start in its folder.');
project.addEventListener('change', () => {
if (!name.value.trim()) name.placeholder = project.value || 'e.g. pbs-upgrade';
});
const result = await openDialog({
title: 'New session',
body: el('div', { class: 'dd-dialog-fields' },
el('label', { class: 'dd-field' }, el('span', {}, 'Name'), name),
el('label', { class: 'dd-field' }, el('span', {}, 'Project folder'), project),
hint),
actions: [{ label: 'Cancel' }, { label: 'Open', primary: true }],
submit: () => {
const value = slugify(name.value) || project.value;
if (!value) {
hint.textContent = 'Type a name or pick a project.';
hint.classList.add('is-error');
name.focus();
return null;
}
return { name: value, project: project.value };
},
});
if (result) await this.create(result.name, result.project);
}
async rename(name) {
const answer = window.prompt(`Rename session "${name}" to:`, name);
if (answer === null) return;
const next = slugify(answer);
if (!next || next === name) {
if (!next) this.setStatus('Names use letters, numbers, - and _.', true);
return;
}
if (await this.act('renaming', 'rename', name, next)) {
const input = el('input', { class: 'lt-input', maxlength: '64', value: name, spellcheck: 'false', autocomplete: 'off', 'aria-describedby': 'dd-rename-hint' });
const hint = el('p', { class: 'dd-dialog-hint', id: 'dd-rename-hint' }, 'Claude keeps running; the conversation moves with the session.');
const next = await openDialog({
title: `Rename ${name}`,
size: 'sm',
body: el('div', { class: 'dd-dialog-fields' }, el('label', { class: 'dd-field' }, el('span', {}, 'New name'), input), hint),
actions: [{ label: 'Cancel' }, { label: 'Rename', primary: true }],
onOpen: () => {
input.select();
return input;
},
submit: () => {
const value = slugify(input.value);
if (!value) {
hint.textContent = 'Names use letters, numbers, - and _.';
hint.classList.add('is-error');
return null;
}
if (value !== name && this.data?.sessions?.some((x) => x.name === value)) {
hint.textContent = `A session called ${value} already exists.`;
hint.classList.add('is-error');
return null;
}
return value;
},
});
if (!next || next === name) return;
if (await this.act('rename', name, next)) {
renameRecent(name, next);
this.hooks.onRenamed(name, next);
await this.refresh();
toast.success(`Renamed ${name} → ${next}`);
await this.reload();
}
}
async end(session) {
const question = session.running
? `End session "${session.name}"?\n\nClaude in this session stops. The conversation stays in Claude's history (claude --resume).`
: `Forget dormant session "${session.name}"?`;
if (!window.confirm(question)) return;
const ok = await confirmDialog({
title: session.running ? `End ${session.name}?` : `Forget ${session.name}?`,
text: session.running
? 'Claude in this session stops and the session closes. The conversation stays in Claude\'s history (claude --resume).'
: 'This dormant session is removed from the list. Its conversation stays in Claude\'s history.',
confirmLabel: session.running ? 'End session' : 'Forget',
danger: true,
});
if (!ok) return;
if (session.name === this.current) this.hooks.onEnding?.(session.name);
if (await this.act(session.running ? 'ending' : 'forgetting', 'end', session.name)) {
if (await this.act('end', session.name)) {
forgetRecent(session.name);
this.hooks.onEnded(session.name);
await this.refresh();
toast.success(session.running ? `Ended ${session.name}` : `Forgot ${session.name}`);
await this.reload();
} else if (session.name === this.current) {
this.hooks.onEndAborted?.();
}
@@ -250,12 +321,6 @@ export class SessionsPanel {
if (!items.length) items.push(el('li', { class: 'dd-empty' }, 'No sessions yet.'));
this.list.replaceChildren(...items);
const projects = this.data?.projects ?? [];
const chosen = this.select.value;
this.select.replaceChildren(
el('option', { value: '' }, `No project (${this.data?.root ?? 'default folder'})`),
...projects.filter((p) => p.name !== this.data?.default)
.map((p) => el('option', { value: p.name, title: p.dir }, p.name)));
this.select.value = chosen;
this.projectOptions(this.select);
}
}
+61 -26
View File
@@ -15,30 +15,65 @@ const writeOnlyClipboard = {
},
};
// LotusGuild TDS tokens (web_template base.css :root) mapped onto the ANSI palette.
export const THEME = Object.freeze({
background: '#010304',
foreground: '#c4d9ee',
cursor: '#FF6B00',
cursorAccent: '#010304',
selectionBackground: 'rgba(0, 212, 255, 0.30)',
selectionInactiveBackground: 'rgba(0, 212, 255, 0.15)',
black: '#0d1520',
red: '#FF2D55',
green: '#00FF88',
yellow: '#FFB300',
blue: '#3A8DFF',
magenta: '#BF5FFF',
cyan: '#00D4FF',
white: '#c4d9ee',
brightBlack: '#3e607a',
brightRed: '#FF5C7A',
brightGreen: '#33FFAA',
brightYellow: '#FFD700',
brightBlue: '#6AA8FF',
brightMagenta: '#D08CFF',
brightCyan: '#33DFFF',
brightWhite: '#FFFFFF',
// Terminal palettes from the LotusGuild TDS tokens (devdesk#31).
//
// DevDesk runs Claude with its `dark-ansi` theme, so Claude draws with these 16
// colours instead of fixed RGB: body text is bright white, secondary text is
// white, the band behind your messages is bright black, and text on coloured
// backgrounds is black. Claude's theme is per process and printed text keeps its
// colours, so instead of switching Claude, the light palette inverts the grey
// ramp: bright white is ink, white is muted ink, bright black a light band and
// black the page. Anything written for dark terminals, Claude's scrollback
// included, stays readable, and toggling is instant.
export const THEMES = Object.freeze({
dark: Object.freeze({
background: '#010304',
foreground: '#c4d9ee',
cursor: '#FF6B00',
cursorAccent: '#010304',
selectionBackground: 'rgba(0, 212, 255, 0.30)',
selectionInactiveBackground: 'rgba(0, 212, 255, 0.15)',
black: '#0d1520',
red: '#FF2D55',
green: '#00FF88',
yellow: '#FFB300',
blue: '#3A8DFF',
magenta: '#BF5FFF',
cyan: '#00D4FF',
white: '#9fb6cc',
brightBlack: '#3e607a',
brightRed: '#FF5C7A',
brightGreen: '#33FFAA',
brightYellow: '#FFD700',
brightBlue: '#6AA8FF',
brightMagenta: '#D08CFF',
brightCyan: '#33DFFF',
brightWhite: '#FFFFFF',
}),
light: Object.freeze({
background: '#f4f6fa',
foreground: '#16202e',
cursor: '#c44e00',
cursorAccent: '#f4f6fa',
selectionBackground: 'rgba(0, 98, 184, 0.22)',
selectionInactiveBackground: 'rgba(0, 98, 184, 0.12)',
black: '#eef1f6',
red: '#b5001f',
green: '#006d35',
yellow: '#8a5a00',
blue: '#1f4fbf',
magenta: '#6b2fb8',
cyan: '#006b86',
white: '#556578',
brightBlack: '#c9d1dc',
brightRed: '#cc1f3d',
brightGreen: '#00803e',
brightYellow: '#9a6400',
brightBlue: '#0062b8',
brightMagenta: '#7e3fd0',
brightCyan: '#007f9e',
brightWhite: '#16202e',
}),
});
export const FONT_FAMILY = '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace';
@@ -55,7 +90,7 @@ export async function loadFonts(size) {
}
}
export function createTerminal(container, { fontSize, onShiftEnter }) {
export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnter }) {
const term = new Terminal({
fontFamily: FONT_FAMILY,
fontSize,
@@ -65,7 +100,7 @@ export function createTerminal(container, { fontSize, onShiftEnter }) {
allowProposedApi: true, // unicode11
macOptionIsMeta: true,
drawBoldTextInBrightColors: false,
theme: THEME,
theme: THEMES[theme],
});
const fit = new FitAddon();
+62
View File
@@ -0,0 +1,62 @@
// Dark / light theme, interchangeable with web_template's lt.theme (devdesk#31):
// same localStorage key (lt_theme), same data-theme attribute on <html>, the OS
// preference while nothing is saved, and tabs follow each other via `storage`.
// The inline script in index.template.html applies it before first paint.
const KEY = 'lt_theme';
const prefersLight = window.matchMedia('(prefers-color-scheme: light)');
const listeners = new Set();
function saved() {
try {
const value = localStorage.getItem(KEY);
return value === 'light' || value === 'dark' ? value : null;
} catch {
return null;
}
}
export function getTheme() {
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
}
function apply(theme) {
const root = document.documentElement;
root.setAttribute('data-theme', theme);
root.style.colorScheme = theme;
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', theme === 'light' ? '#edf0f5' : '#030508');
const next = theme === 'light' ? 'dark' : 'light';
for (const button of document.querySelectorAll('.lt-theme-btn')) {
button.textContent = theme === 'light' ? '◐' : '☀';
button.setAttribute('aria-label', `Switch to ${next} mode`);
button.title = `Switch to ${next} mode`;
}
for (const listener of listeners) listener(theme);
}
export function setTheme(theme) {
try {
localStorage.setItem(KEY, theme);
} catch {
// Private mode: the choice lasts until the tab closes.
}
apply(theme);
}
export function toggleTheme() {
setTheme(getTheme() === 'light' ? 'dark' : 'light');
}
export function onThemeChange(listener) {
listeners.add(listener);
}
export function initTheme() {
apply(saved() ?? (prefersLight.matches ? 'light' : 'dark'));
prefersLight.addEventListener('change', (event) => {
if (!saved()) apply(event.matches ? 'light' : 'dark');
});
window.addEventListener('storage', (event) => {
if (event.key === KEY) apply(saved() ?? (prefersLight.matches ? 'light' : 'dark'));
});
}
+295
View File
@@ -0,0 +1,295 @@
// House UI pieces built on web_template's base.css classes, without base.js
// (its global key handler swallows Esc, which interrupts Claude, and every toast
// beeps): toasts (devdesk#34), dialogs (#33) and the command palette (#32).
import { el } from './dom.js';
// ---------------------------------------------------------------- toasts
const ICONS = { success: '✓', error: '✗', warning: '!', info: 'i' };
function toastContainer() {
let box = document.getElementById('lt-toast-container');
if (!box) {
box = el('div', { id: 'lt-toast-container', 'aria-live': 'polite' });
document.body.append(box);
}
return box;
}
function dismiss(node) {
if (!node.isConnected) return;
clearTimeout(node.dismissTimer);
node.classList.add('is-leaving');
setTimeout(() => node.remove(), 250);
}
export function toast(message, type = 'info', duration = 4000) {
const box = toastContainer();
// At most four on screen: the oldest goes first.
while (box.children.length >= 4) box.firstElementChild.remove();
const node = el('div', { class: `lt-toast lt-toast-${type}`, role: type === 'error' ? 'alert' : 'status' },
el('span', { class: 'lt-toast-icon', 'aria-hidden': 'true' }, `[${ICONS[type] || 'i'}]`),
el('span', { class: 'lt-toast-msg' }, message),
el('button', { type: 'button', class: 'lt-toast-close', 'aria-label': 'Dismiss', onclick: () => dismiss(node) }, '✕'),
el('div', { class: 'lt-toast-progress', style: `animation-duration:${duration}ms` }));
box.append(node);
node.dismissTimer = setTimeout(() => dismiss(node), duration);
node.addEventListener('mouseenter', () => clearTimeout(node.dismissTimer));
node.addEventListener('mouseleave', () => { node.dismissTimer = setTimeout(() => dismiss(node), 1500); });
}
toast.success = (message, duration) => toast(message, 'success', duration);
toast.error = (message, duration = 8000) => toast(message, 'error', duration);
toast.warning = (message, duration) => toast(message, 'warning', duration);
toast.info = (message, duration) => toast(message, 'info', duration);
// ---------------------------------------------------------------- dialogs
let openDialogs = 0;
export const dialogOpen = () => openDialogs > 0 || document.getElementById('lt-cmd-overlay')?.classList.contains('is-open');
// 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 }) {
return new Promise((resolve) => {
const opener = document.activeElement;
let done = false;
const close = (value) => {
if (done) return;
done = true;
openDialogs -= 1;
overlay.remove();
if (opener?.isConnected) opener.focus();
resolve(value);
};
const titleId = `dd-dialog-${Date.now()}`;
const form = el('form', { class: 'lt-modal-body dd-dialog-body', novalidate: '' }, body);
form.addEventListener('submit', (event) => {
event.preventDefault();
const value = submit ? submit() : true;
if (value !== undefined && value !== null && value !== false) close(value);
});
const buttons = actions.map((action) => el('button', {
type: action.primary ? 'submit' : 'button',
class: `lt-btn lt-btn-sm${action.primary ? (action.danger ? ' lt-btn-danger' : ' lt-btn-primary') : ' lt-btn-ghost'}`,
onclick: action.primary ? null : () => close(action.value ?? null),
}, action.label));
// The primary button submits the form, so Enter in a field does the same.
for (const button of buttons) {
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` },
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),
el('button', { type: 'button', class: 'lt-modal-close', 'aria-label': 'Close', onclick: () => close(null) }, '✕')),
form,
buttons.length ? el('div', { class: 'lt-modal-footer' }, ...buttons) : null));
overlay.addEventListener('mousedown', (event) => {
if (event.target === overlay) close(null);
});
overlay.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
close(null);
return;
}
if (event.key === 'Tab') trapFocus(overlay, event);
});
openDialogs += 1;
document.body.append(overlay);
(onOpen?.(overlay) ?? overlay.querySelector('input, select, textarea, .lt-btn-primary, .lt-btn-danger, .lt-modal-close'))?.focus?.();
});
}
function trapFocus(root, event) {
const items = [...root.querySelectorAll('button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])')]
.filter((node) => !node.disabled && node.offsetParent !== null);
if (!items.length) return;
const first = items[0];
const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
export function confirmDialog({ title, text, confirmLabel = 'OK', danger = false }) {
return openDialog({
title,
danger,
size: 'sm',
body: el('p', { class: 'dd-dialog-text' }, text),
actions: [{ label: 'Cancel', value: false }, { label: confirmLabel, primary: true, danger }],
}).then(Boolean);
}
// ---------------------------------------------------------------- command palette
const RECENT_KEY = 'lt_cmd_recent';
function readRecent() {
try {
const list = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]');
return Array.isArray(list) ? list : [];
} catch {
return [];
}
}
function pushRecent(id) {
try {
localStorage.setItem(RECENT_KEY, JSON.stringify([id, ...readRecent().filter((x) => x !== id)].slice(0, 5)));
} catch {
// Private mode: no recents.
}
}
// Commands: { id, label, group, icon?, hint?, kbd?, keywords?, run }. `getCommands`
// is called on every open, so the list reflects the current sessions and state.
export class CommandPalette {
constructor(getCommands) {
this.getCommands = getCommands;
this.commands = [];
this.visible = [];
this.selected = 0;
this.opener = null;
this.input = el('input', {
class: 'lt-cmd-input', type: 'text', placeholder: 'Type a command or a session name…',
autocomplete: 'off', spellcheck: 'false', 'aria-label': 'Command', role: 'combobox',
'aria-controls': 'dd-cmd-results', 'aria-expanded': 'true',
});
this.results = el('div', { class: 'lt-cmd-results', id: 'dd-cmd-results', role: 'listbox' });
this.overlay = el('div', { class: 'lt-cmd-overlay', id: 'lt-cmd-overlay', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Command palette' },
el('div', { class: 'lt-cmd-palette', id: 'lt-cmd-palette' },
el('div', { class: 'lt-cmd-input-wrap' }, el('span', { class: 'lt-cmd-prompt', 'aria-hidden': 'true' }, '>'), this.input),
this.results,
el('div', { class: 'lt-cmd-footer' },
el('span', {}, el('kbd', {}, '↑'), el('kbd', {}, '↓'), ' move'),
el('span', {}, el('kbd', {}, '⏎'), ' run'),
el('span', {}, el('kbd', {}, 'Esc'), ' close'))));
document.body.append(this.overlay);
this.input.addEventListener('input', () => this.render());
this.input.addEventListener('keydown', (event) => this.onKey(event));
this.overlay.addEventListener('mousedown', (event) => {
if (event.target === this.overlay) this.close();
});
}
get isOpen() {
return this.overlay.classList.contains('is-open');
}
open(query = '') {
this.opener = document.activeElement;
this.commands = this.getCommands();
this.overlay.classList.add('is-open');
this.input.value = query;
this.render();
this.input.focus();
}
close({ restoreFocus = true } = {}) {
if (!this.isOpen) return;
this.overlay.classList.remove('is-open');
if (restoreFocus && this.opener?.isConnected) this.opener.focus();
}
onKey(event) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
this.move(event.key === 'ArrowDown' ? 1 : -1);
} else if (event.key === 'Enter') {
event.preventDefault();
this.run(this.visible[this.selected]);
} else if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.close();
} else if (event.key === 'Tab') {
event.preventDefault();
}
}
move(step) {
if (!this.visible.length) return;
this.selected = (this.selected + step + this.visible.length) % this.visible.length;
this.highlight();
}
highlight() {
const items = this.results.querySelectorAll('.lt-cmd-item');
items.forEach((item, i) => {
item.classList.toggle('is-selected', i === this.selected);
item.setAttribute('aria-selected', String(i === this.selected));
});
const current = items[this.selected];
if (current) {
current.scrollIntoView({ block: 'nearest' });
this.input.setAttribute('aria-activedescendant', current.id);
}
}
run(command) {
if (!command) return;
pushRecent(command.id);
// The command decides where focus goes (usually the terminal).
this.close({ restoreFocus: false });
command.run();
}
render() {
const words = this.input.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
const matches = this.commands.filter((c) => {
const text = `${c.label} ${c.group} ${c.keywords ?? ''} ${c.hint ?? ''}`.toLowerCase();
return words.every((w) => text.includes(w));
});
const groups = new Map();
if (!words.length) {
const recent = readRecent().map((id) => matches.find((c) => c.id === id)).filter(Boolean).slice(0, 3);
if (recent.length) groups.set('Recent', recent);
}
for (const command of matches) {
if (!groups.has(command.group)) groups.set(command.group, []);
groups.get(command.group).push(command);
}
this.visible = [...groups.values()].flat();
this.selected = 0;
if (!this.visible.length) {
this.results.replaceChildren(el('div', { class: 'lt-cmd-empty' }, `No command matches “${this.input.value.trim()}”`));
return;
}
let index = 0;
const nodes = [];
for (const [group, commands] of groups) {
nodes.push(el('div', { class: 'lt-cmd-group-label', role: 'presentation' }, group));
for (const command of commands) {
const i = index;
nodes.push(el('div', {
class: 'lt-cmd-item', id: `dd-cmd-${i}`, role: 'option',
onclick: () => this.run(command),
onmousemove: () => {
if (this.selected !== i) {
this.selected = i;
this.highlight();
}
},
},
el('span', { class: 'lt-cmd-item-icon', 'aria-hidden': 'true' }, command.icon || '◦'),
el('span', { class: 'lt-cmd-item-label' }, command.label),
command.hint ? el('span', { class: 'dd-cmd-hint' }, command.hint) : null,
command.kbd ? el('span', { class: 'lt-cmd-item-kbd' }, command.kbd) : null));
index += 1;
}
}
this.results.replaceChildren(...nodes);
this.highlight();
}
}
+144 -14
View File
@@ -100,20 +100,7 @@ body.dd-app::after {
color: var(--accent-orange);
border-color: var(--accent-orange-border);
}
.dd-badge {
margin-left: 6px;
min-width: 16px;
padding: 0 4px;
border-radius: 8px;
background: var(--accent-amber);
color: var(--bg-primary);
font-size: 0.62rem;
font-weight: 700;
line-height: 16px;
text-align: center;
}
.dd-badge.is-urgent { background: var(--accent-red); box-shadow: 0 0 8px var(--accent-red); }
.dd-badge[hidden] { display: none; }
/* ---- menus (session switcher, ⋯) ----------------------------------------------- */
.dd-session,
@@ -569,3 +556,146 @@ body.dd-touch .dd-footer {
.dd-footer .lt-footer-hints .lt-footer-hint:nth-child(n + 5),
.dd-footer .lt-footer-sep:nth-child(n + 4) { display: none; }
}
/* ---- light theme (devdesk#31) ---------------------------------------------------
base.css restyles every lt- component; these cover DevDesk's own pieces. */
html[data-theme="light"] .dd-sidebar,
html[data-theme="light"] .dd-footer { background-image: none; }
html[data-theme="light"] .dd-overlay { background: rgba(237, 240, 245, 0.82); }
html[data-theme="light"] .dd-backdrop { background: rgba(17, 24, 39, 0.28); }
html[data-theme="light"] .dd-menu { box-shadow: 0 8px 28px rgba(50, 80, 130, 0.22), var(--box-glow-cyan); }
html[data-theme="light"] .dd-drop-overlay { background: var(--accent-cyan-dim); }
html[data-theme="light"] .dd-upload-status { box-shadow: 0 4px 16px rgba(50, 80, 130, 0.18); }
html[data-theme="light"] .dd-session-item.is-waiting,
html[data-theme="light"] .dd-session-chip[data-state="waiting"],
html[data-theme="light"] .dd-upload-status.is-error { border-color: rgba(181, 0, 31, 0.35); }
html[data-theme="light"] .dd-session-chip[data-state="working"] .dd-state-dot,
html[data-theme="light"] .dd-session-chip[data-state="waiting"] .dd-state-dot { box-shadow: none; }
@media (max-width: 1099px) {
html[data-theme="light"] .dd-sidebar { box-shadow: -8px 0 28px rgba(50, 80, 130, 0.22); }
}
.dd-header .lt-theme-btn { flex: 0 0 auto; }
/* ---- command palette, dialogs, toasts (devdesk#32-#34) ---------------------------- */
.dd-cmd-btn { font-size: 0.8rem; letter-spacing: 0.02em; }
.dd-cmd-kbd { font-size: 0.62rem; opacity: 0.7; }
.lt-cmd-item.is-selected .dd-cmd-hint { color: inherit; }
.dd-cmd-hint { color: var(--text-muted); font-size: 0.66rem; white-space: nowrap; }
html[data-theme="light"] .lt-cmd-item.is-selected { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
.lt-cmd-results { max-height: min(420px, 60vh); }
.lt-cmd-footer span { display: inline-flex; align-items: center; gap: 2px; }
.dd-dialog-body { display: flex; flex-direction: column; gap: var(--space-sm); }
.dd-dialog-fields { display: flex; flex-direction: column; gap: var(--space-md); }
.dd-field { display: flex; flex-direction: column; gap: 4px; font-size: 0.72rem; color: var(--text-secondary); }
.dd-field .lt-input,
.dd-field .lt-select { width: 100%; font-size: 0.8rem; }
.dd-dialog-text { color: var(--text-secondary); font-size: 0.8rem; line-height: 1.5; }
.dd-dialog-hint { color: var(--text-muted); font-size: 0.7rem; }
.dd-dialog-hint.is-error { color: var(--accent-red); }
.dd-keys { display: grid; gap: var(--space-lg); }
.dd-keys-title {
margin: 0 0 var(--space-sm);
font-size: 0.68rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent-orange);
}
.dd-keys-list { display: grid; grid-template-columns: 10.5rem 1fr; gap: 6px var(--space-md); margin: 0; font-size: 0.76rem; }
.dd-keys-list dt { white-space: nowrap; }
.dd-keys-list dd { margin: 0; color: var(--text-secondary); }
.dd-keys kbd {
padding: 1px 6px;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--accent-cyan);
font-family: var(--font-mono);
font-size: 0.72rem;
}
.dd-footer-help {
margin-left: var(--space-sm);
padding: 0 6px;
border: 1px solid var(--border-color);
background: none;
color: var(--accent-cyan);
font-family: var(--font-mono);
font-size: 0.64rem;
cursor: pointer;
}
.dd-footer-help:hover { border-color: var(--accent-cyan); }
/* Toasts sit above the footer (and the phone key bar); names keep their case. */
#lt-toast-container {
right: var(--space-md);
bottom: calc(var(--dd-footer-height) + var(--dd-kb, 0px) + env(safe-area-inset-bottom, 0px) + var(--space-md));
max-width: calc(100vw - 2 * var(--space-md));
}
.lt-toast {
position: relative;
overflow: hidden;
text-transform: none;
letter-spacing: 0.02em;
font-size: 0.74rem;
transition: opacity 0.25s ease;
}
.lt-toast.is-leaving { opacity: 0; }
.lt-toast-msg { flex: 1; overflow-wrap: anywhere; }
.lt-toast-icon { flex: 0 0 auto; }
.lt-toast-close {
flex: 0 0 auto;
background: none;
border: 0;
color: inherit;
opacity: 0.6;
cursor: pointer;
font-size: 0.8rem;
}
.lt-toast-close:hover { opacity: 1; }
@media (max-width: 719px) {
.dd-cmd-kbd { display: none; }
#lt-toast-container { left: var(--space-sm); right: var(--space-sm); }
.lt-toast { min-width: 0; max-width: none; }
.dd-keys-list { grid-template-columns: 1fr; gap: 2px; }
.dd-keys-list dd { margin-bottom: var(--space-sm); }
}
/* One highlight only: the row under a resting pointer isn't "selected" until the
pointer moves (mousemove updates the selection). */
.lt-cmd-item:hover:not(.is-selected) { background: none; color: inherit; }
/* ---- inbox (bell) ---------------------------------------------------------------- */
.dd-inbox .lt-notif-bell-btn { font-size: 0.9rem; line-height: 1; padding-inline: 8px; }
.dd-inbox .lt-notif-badge { top: -5px; right: -5px; }
.dd-inbox .lt-notif-badge.is-calm { background: var(--accent-amber); animation: none; }
.dd-inbox-panel { width: min(360px, calc(100vw - 16px)); z-index: 40; }
.dd-inbox-panel .lt-notif-panel-list { max-height: min(460px, calc(100vh - 140px)); }
.dd-notif-section {
padding: 6px 0.75rem 4px;
font-size: 0.62rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent-orange);
border-bottom: 1px solid var(--border-dim);
background: var(--bg-primary);
}
.dd-notif-red { background: var(--accent-red); box-shadow: 0 0 6px var(--accent-red); }
.dd-notif-amber { background: var(--accent-amber); box-shadow: 0 0 6px var(--accent-amber); }
.dd-notif-green { background: var(--accent-green); box-shadow: 0 0 6px var(--accent-green); }
.dd-notif-cyan { background: var(--accent-cyan); }
.dd-notif-read { background: var(--border-color); box-shadow: none; }
html[data-theme="light"] .lt-notif-dot { box-shadow: none; }
.dd-notif-empty,
.dd-notif-more { padding: 0.75rem; color: var(--text-muted); font-size: 0.72rem; line-height: 1.5; }
.dd-panels-icon { display: none; }
@media (max-width: 719px) {
/* Phone header: theme lives in the ⋯ menu; Panels becomes an icon. */
.dd-header .lt-theme-btn { display: none; }
.dd-inbox .lt-notif-badge { top: 1px; right: 1px; }
.dd-panels-label { display: none; }
.dd-panels-icon { display: inline; font-size: 1rem; }
.dd-inbox-panel { position: fixed; top: calc(var(--header-height) + 4px); right: 8px; left: 8px; width: auto; }
}