Add Web Push, guided start screen, SVG icons, chunked uploads, rename

- Web Push (VAPID + RFC 8291, pure Rust): permission/question/finished
  notifications while no app window is visible; settings with test button
- Guided new-session flow: task, craft target (website/app/game/old
  software/fresh), clean room or deep, description, model, folder
  (new, existing or cloned git repository); originals upload to original/
- Craft phase bar (craft_phase tool) for study/specify/build/check/ready
- Chunked uploads (8 MB) up to server.max_upload_mb (default 500 MB)
- All UI glyphs and emojis replaced by themeable SVG icons
- Rename dialog with manual name or model-generated suggestion

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 21:03:26 +01:00
1 parent 013695722a
commit a0d0e707f2
48 files changed
+2724 -228

No files matched your search

+59 -31
View File
@@ -1,8 +1,9 @@
import { Icon } from "./Icon";
import { base64Size, FileChip, FileFigure, fileType } from "./FileFigure";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { del, get, patch, post } from "../api";
import { useModels } from "../hooks";
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT } from "../i18n";
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
import { navigate } from "../router";
import { dismissError, loadSession, loadSessions, useStore } from "../store";
import type { Aggregate, ImageData, Message, Part, Session } from "../types";
@@ -10,6 +11,7 @@ import { ActivityLine, Dots, ThinkIcon, WorkBar } from "./Activity";
import { useReasoningMode } from "../prefs";
import { Composer } from "./Composer";
import { ShareDialog } from "./ShareDialog";
import { RenameDialog } from "./RenameDialog";
import { QueueList } from "./QueueList";
import { BudgetChip } from "./Budget";
import { PermissionCard, QuestionCard } from "./Interaction";
@@ -47,7 +49,7 @@ function Reasoning({ text, live }: { text: string; live: boolean }) {
<button className="reasoning-head" onClick={() => setToggled(!open)} aria-expanded={open}>
{live ? <Dots /> : <ThinkIcon />}
<span>{live ? t("chat.reasoningLive") : t("chat.reasoningDone", { n: words })}</span>
<span aria-hidden="true">{open ? "▾" : "▸"}</span>
<Icon name={open ? "chevron-down" : "chevron-right"} />
</button>
{open && (
<div ref={bodyRef} className={`body ${live ? "streaming-text" : ""}`}>
@@ -240,27 +242,22 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
☰
<Icon name="menu" />
</button>
<div className="grow">
{editingTitle ? (
<input
autoFocus
defaultValue={session.title}
onBlur={(e) => (setEditingTitle(false), update({ title: e.target.value }))}
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
style={{ width: "100%" }}
/>
) : (
<h1 onDoubleClick={() => setEditingTitle(true)} title={t("chat.renameHint")}>
{session.parent_id && (
<a href={`#/s/${session.parent_id}`} style={{ marginRight: 8 }}>
↩
</a>
)}
{session.title || t("session.untitled")}
</h1>
)}
<h1 onDoubleClick={() => !readOnly && setEditingTitle(true)} title={readOnly ? undefined : t("chat.renameHint")}>
{session.parent_id && (
<a href={`#/s/${session.parent_id}`} style={{ marginRight: 8 }}>
<Icon name="undo" />
</a>
)}
{session.title || t("session.untitled")}
{!readOnly && (
<button className="ghost small title-edit" onClick={() => setEditingTitle(true)} title={t("rename.title")} aria-label={t("rename.title")}>
<Icon name="edit" />
</button>
)}
</h1>
<div className="path">{session.cwd}</div>
</div>
{ctxShare !== undefined && (
@@ -271,8 +268,8 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
{stats && (
<button className="chip" style={{ border: "none" }} onClick={() => setShowStats((x) => !x)} title={t("chat.statsHint")}>
<span className="hide-mobile">
{fmtCompact(stats.total.input_tokens + stats.total.cache_read_tokens + stats.total.cache_write_tokens)}↑{" "}
{fmtCompact(stats.total.output_tokens)}↓ ·{" "}
{fmtCompact(stats.total.input_tokens + stats.total.cache_read_tokens + stats.total.cache_write_tokens)}<Icon name="arrow-up" className="tiny" />{" "}
{fmtCompact(stats.total.output_tokens)}<Icon name="arrow-down" className="tiny" /> ·{" "}
</span>
{fmtCost(stats.total.cost, stats.currency)}
</button>
@@ -285,17 +282,17 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
<>
{!session.parent_id && (
<button className="small ghost" onClick={() => setSharing(true)} title={t("share.title")}>
⇄
<Icon name="share" />
</button>
)}
{!session.parent_id && (
<button className="small ghost" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
🔀
<Icon name="fork" />
</button>
)}
<details className="menu-pop">
<summary className="small ghost" title={t("chat.export")}>
💾
<Icon name="download" />
</summary>
<div className="menu-pop-body">
<a href={`/api/sessions/${sessionId}/export?format=md`} download>
@@ -307,14 +304,15 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
</div>
</details>
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}>
⇊
<Icon name="compact" />
</button>
<button className="small ghost danger" onClick={remove} title={t("chat.delete")}>
🗑
<Icon name="trash" />
</button>
</>
)}
</div>
{session.agent === "craft" && !session.parent_id && <PhaseBar phase={session.phase} deep={session.clean_room === false} />}
<WorkBar active={busy && permissions.length === 0 && questions.length === 0} />
<div
className="chat-scroll"
@@ -375,7 +373,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
{!busy && !readOnly && (
<div className="msg-actions">
<button className="small ghost" onClick={() => rewind(m)} title={t("chat.rewind")}>
✎
<Icon name="edit" />
</button>
</div>
)}
@@ -417,7 +415,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
<div key={e.id} className="error-box" role="alert">
<span>{e.message}</span>
<button className="small ghost" onClick={() => dismissError(sessionId, e.id)} aria-label={t("common.close")}>
×
<Icon name="close" />
</button>
</div>
))}
@@ -438,11 +436,12 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
{!following && (
<div className="jump-latest">
<button className="primary" onClick={jumpToLatest} title={t("chat.jumpLatest")} aria-label={t("chat.jumpLatest")}>
↓
<Icon name="arrow-down" />
</button>
</div>
)}
</div>
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
@@ -500,3 +499,32 @@ function fmtK(n: number) {
if (n >= 1000) return `${Math.round(n / 1000)}k`;
return String(n);
}
/** Craft workflow progress: Study → (Specify) → Build → Check → Ready. */
function PhaseBar({ phase, deep }: { phase?: string; deep: boolean }) {
const t = useT();
const phases = deep ? ["study", "specify", "build", "check", "ready"] : ["study", "build", "check", "ready"];
const cur = phase ? phases.indexOf(phase) : -1;
return (
<div className="phase-bar" aria-label="Craft progress">
{phases.map((p, i) => {
const state = i < cur || (p === "ready" && cur === i) ? "done" : i === cur ? "current" : "";
return (
<span key={p} style={{ display: "contents" }}>
{i > 0 && <span className={`sep ${i <= cur ? "done" : ""}`} />}
<span className={`phase ${state}`}>
<span className="dot">
{state === "done" && (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m5 12 4.5 4.5L19 7" />
</svg>
)}
</span>
{t(`phase.${p}` as Key)}
</span>
</span>
);
})}
</div>
);
}