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:
1 parent
013695722a
commit
a0d0e707f2
48 files changed
+2724
-228
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user