Quality of life: in-app dialogs, selections in the URL, editable widgets, token dialog, gallery search, status history, stream errors that say why, tab reordering and shortcuts, log out everywhere

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-19 10:34:59 -07:00
co-authored by Claude Fable 5.1
parent 8ee2a2d293
commit d7ed4d8549
24 changed files with 361 additions and 101 deletions
+49 -3
View File
@@ -17,7 +17,7 @@ export function Btn({ onClick, busy, confirm, className = 'btn', disabled, title
return (
<button className={className + (b ? ' busy' : '')} disabled={disabled || b} title={title}
onClick={async () => {
if (confirm && !window.confirm(confirm)) return
if (confirm && !(await askYes(confirm))) return
const r = onClick()
if (r && typeof (r as Promise<unknown>).then === 'function') {
setInner(true)
@@ -80,10 +80,56 @@ export function useEscape(fn: () => void, active = true) {
}, [active])
}
// ---- dialogs: askYes / askText replace the browser's confirm() and prompt() ----
// Same promise shape as the natives (false / null on cancel), drawn like the rest of the
// console, usable on a phone, closed by Escape or a tap outside.
interface Ask { id: number; msg: string; input?: string; ok: string; danger: boolean; done: (v: string | boolean | null) => void }
let asks: Ask[] = []
let nextId = 0
const askListeners = new Set<(a: Ask[]) => void>()
const emitAsks = () => askListeners.forEach((l) => l(asks))
function ask(msg: string, input: string | undefined, ok?: string): Promise<string | boolean | null> {
return new Promise((resolve) => {
const id = ++nextId
const danger = /^(delete|remove|revoke|close|send sig|stop|disable|reset|discard|force)/i.test(msg) || /\bforce\b/i.test(msg)
asks = [...asks, { id, msg, input, ok: ok ?? (input !== undefined ? 'OK' : danger ? msg.split(/[ ?]/)[0] : 'OK'), danger, done: (v) => { asks = asks.filter((a) => a.id !== id); emitAsks(); resolve(v) } }]
emitAsks()
})
}
export const askYes = (msg: string, ok?: string) => ask(msg, undefined, ok) as Promise<boolean>
export const askText = (msg: string, initial = '', ok?: string) => ask(msg, initial, ok) as Promise<string | null>
function AskBox({ a }: { a: Ask }) {
const [val, setVal] = useState(a.input ?? '')
const cancel = () => a.done(a.input !== undefined ? null : false)
const accept = () => a.done(a.input !== undefined ? val : true)
useEscape(cancel)
return (
<div className="modal-back" onClick={cancel}>
<form className="modal ask" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); accept() }}>
<div className="ask-msg">{a.msg}</div>
{a.input !== undefined && <input autoFocus value={val} onChange={(e) => setVal(e.target.value)} onFocus={(e) => e.target.select()} spellCheck={false} autoCapitalize="off" autoCorrect="off" />}
<div className="svc-actions">
<button type="submit" autoFocus={a.input === undefined} className={'btn ' + (a.danger ? 'danger' : 'on')}>{a.ok}</button>
<span className="spacer" />
<button type="button" className="btn" onClick={cancel}>Cancel</button>
</div>
</form>
</div>
)
}
export function Dialogs() {
const [list, setList] = useState<Ask[]>(asks)
useEffect(() => { askListeners.add(setList); return () => { askListeners.delete(setList) } }, [])
const top = list[list.length - 1]
return top ? <AskBox key={top.id} a={top} /> : null
}
// Unsaved work: whoever holds some says so, and whoever is about to navigate away asks.
let unsaved = ''
export function setUnsaved(what: string) { unsaved = what }
export function okToLeave(): boolean {
if (!unsaved || window.confirm(`Discard unsaved changes to ${unsaved}?`)) { unsaved = ''; return true }
export const hasUnsaved = () => unsaved !== ''
export async function okToLeave(): Promise<boolean> {
if (!unsaved || (await askYes(`Discard unsaved changes to ${unsaved}?`, 'Discard'))) { unsaved = ''; return true }
return false
}