ui fixes
This commit is contained in:
+65
-17
@@ -27,6 +27,7 @@ import { ReviewPage } from "./components/ReviewPage.tsx";
|
||||
import { ConfirmDialog, LoadingScreen } from "./components/ui.tsx";
|
||||
import { Toasts } from "./components/Toasts.tsx";
|
||||
import { pushToast } from "./lib/toasts.ts";
|
||||
import { clearFailedEntry, getFailedEntry, getSavedModel, pickModel, setFailedEntry, setSavedModel } from "./lib/localPrefs.ts";
|
||||
import { msUntilNextSolarSwitch, resolveThemeDark } from "./lib/solarTheme.ts";
|
||||
import { useT } from "./lib/i18n.ts";
|
||||
|
||||
@@ -126,6 +127,14 @@ export function App() {
|
||||
// its conversation, and how many times this prompt was already retried.
|
||||
const [failed, setFailed] = useState<{ index: number; convId: string } | null>(null);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
// Conversation currently receiving a streamed reply (null when idle).
|
||||
// Streaming / thinking / sending UI is rendered ONLY there — every other
|
||||
// conversation stays clean and selectable while a reply lands.
|
||||
const [sendingFor, setSendingFor] = useState<string | null>(null);
|
||||
function setFlight(id: string | null): void {
|
||||
sendingForRef.current = id;
|
||||
setSendingFor(id);
|
||||
}
|
||||
const { t } = useT();
|
||||
|
||||
// Public routes: no auth needed. IDs are constant for the page lifetime.
|
||||
@@ -199,7 +208,7 @@ export function App() {
|
||||
setReportsEnabled(t.reportsEnabled ?? true);
|
||||
setUsernameChangeEnabled(t.usernameChangeEnabled ?? true);
|
||||
setModels(m.models);
|
||||
if (m.models.length > 0) setModel(m.models[0]!.id);
|
||||
if (m.models.length > 0) setModel(pickModel(m.models, getSavedModel(res.user.username)));
|
||||
setConvs(c.conversations);
|
||||
setConvsReady(true);
|
||||
// Deep link: /chat/<id> opens that conversation directly.
|
||||
@@ -290,9 +299,24 @@ export function App() {
|
||||
// stream lands — the server list is still empty mid-flight, so a refetch
|
||||
// here would blank the fresh exchange (displayed as a recreated chat).
|
||||
if (sendingForRef.current === activeId) return;
|
||||
const id = activeId;
|
||||
api
|
||||
.getConv(activeId)
|
||||
.then((res) => setMessages(res.messages))
|
||||
.getConv(id)
|
||||
.then((res) => {
|
||||
if (activeIdRef.current !== id || sendingForRef.current === id) return;
|
||||
setMessages(res.messages);
|
||||
// A model error is persisted browser-side (never server-side, so
|
||||
// history stays clean): re-create the trailing error bubble + retry
|
||||
// state after a refresh. Stale entries (conversation moved on) drop.
|
||||
const entry = getFailedEntry(id);
|
||||
if (entry && entry.index === res.messages.length) {
|
||||
setMessages([...res.messages, { role: "assistant", content: t("attach.chatError") }]);
|
||||
setFailed({ index: entry.index, convId: id });
|
||||
setRetryCount(entry.retryCount);
|
||||
} else if (entry) {
|
||||
clearFailedEntry(id);
|
||||
}
|
||||
})
|
||||
.catch(() => setMessages([]));
|
||||
}, [activeId]);
|
||||
|
||||
@@ -327,7 +351,7 @@ export function App() {
|
||||
.models()
|
||||
.then((m) => {
|
||||
setModels(m.models);
|
||||
if (m.models.length > 0) setModel(m.models[0]!.id);
|
||||
if (m.models.length > 0) setModel(pickModel(m.models, getSavedModel(u.username)));
|
||||
})
|
||||
.catch(() => {});
|
||||
api
|
||||
@@ -380,7 +404,14 @@ export function App() {
|
||||
}
|
||||
|
||||
async function send(text: string) {
|
||||
if (sending || !model || archivedConv) return;
|
||||
if (sending || !model || archivedConv) {
|
||||
// Single flight: another conversation is streaming — say so instead
|
||||
// of silently swallowing the submit.
|
||||
if (sending && sendingForRef.current && sendingForRef.current !== activeIdRef.current) {
|
||||
pushToast(t("chat.busyOther"), { icon: "model" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
let convId = activeId;
|
||||
if (!convId) {
|
||||
try {
|
||||
@@ -390,16 +421,16 @@ export function App() {
|
||||
// render): the URL is still bare /chat while the selection already
|
||||
// points at the new id, and sendingForRef shields both until the
|
||||
// stream lands. Then refresh the list and move the URL to /chat/<id>.
|
||||
sendingForRef.current = convId;
|
||||
setFlight(convId);
|
||||
setActiveId(convId);
|
||||
await refreshConvs(convId);
|
||||
navigate(`/chat/${convId}`);
|
||||
} catch (err) {
|
||||
sendingForRef.current = null;
|
||||
setFlight(null);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
sendingForRef.current = convId;
|
||||
setFlight(convId);
|
||||
}
|
||||
const userMsg: ChatMessage = { role: "user", content: text };
|
||||
// Attachments travel as reviewed text blocks appended to the message.
|
||||
@@ -412,7 +443,11 @@ export function App() {
|
||||
);
|
||||
if (blocks.length > 0)
|
||||
userMsg.content = [text, ...blocks].filter(Boolean).join("\n\n");
|
||||
const history = [...messages, userMsg];
|
||||
// A trailing error bubble is display-only: never send the fake error
|
||||
// text to the model, whether it was just created or restored.
|
||||
const cleanBase =
|
||||
failed && failed.convId === convId ? messages.filter((_, i) => i !== failed.index) : messages;
|
||||
const history = [...cleanBase, userMsg];
|
||||
setMessages(history);
|
||||
setAttachments([]);
|
||||
setFailed(null);
|
||||
@@ -428,6 +463,7 @@ export function App() {
|
||||
setMessages([...history, { role: "assistant", content: full }]);
|
||||
}
|
||||
setStreaming("");
|
||||
clearFailedEntry(convId!);
|
||||
// Explicit select: never let the post-stream refresh drift the
|
||||
// selection (or the URL) away from this conversation.
|
||||
await refreshConvs(convId!);
|
||||
@@ -440,9 +476,12 @@ export function App() {
|
||||
]);
|
||||
setFailed({ index: history.length, convId: convId! });
|
||||
}
|
||||
// Persist the failure browser-side so the bubble + retry survive a
|
||||
// refresh (nothing fake is written server-side).
|
||||
setFailedEntry(convId!, { index: history.length, retryCount: 0, at: Date.now() });
|
||||
setStreaming("");
|
||||
} finally {
|
||||
if (sendingForRef.current === convId) sendingForRef.current = null;
|
||||
if (sendingForRef.current === convId) setFlight(null);
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
@@ -456,10 +495,11 @@ export function App() {
|
||||
if (sending || !failed || retryCount >= MAX_RETRIES || !model || archivedConv) return;
|
||||
const history = messages.slice(0, failed.index);
|
||||
const convId = failed.convId;
|
||||
sendingForRef.current = convId;
|
||||
const nextCount = retryCount + 1;
|
||||
setFlight(convId);
|
||||
setMessages(history);
|
||||
setFailed(null);
|
||||
setRetryCount((c) => c + 1);
|
||||
setRetryCount(nextCount);
|
||||
setSending(true);
|
||||
setStreaming("");
|
||||
try {
|
||||
@@ -468,6 +508,7 @@ export function App() {
|
||||
setMessages([...history, { role: "assistant", content: full }]);
|
||||
}
|
||||
setRetryCount(0);
|
||||
clearFailedEntry(convId);
|
||||
await refreshConvs(convId);
|
||||
if (activeIdRef.current === convId) navigate(`/chat/${convId}`);
|
||||
} catch {
|
||||
@@ -478,9 +519,10 @@ export function App() {
|
||||
]);
|
||||
setFailed({ index: history.length, convId });
|
||||
}
|
||||
setFailedEntry(convId, { index: history.length, retryCount: nextCount, at: Date.now() });
|
||||
setStreaming("");
|
||||
} finally {
|
||||
if (sendingForRef.current === convId) sendingForRef.current = null;
|
||||
if (sendingForRef.current === convId) setFlight(null);
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
@@ -552,6 +594,7 @@ export function App() {
|
||||
async function removeConv(c: Conversation) {
|
||||
try {
|
||||
await api.deleteConv(c.id);
|
||||
clearFailedEntry(c.id);
|
||||
if (activeId === c.id) {
|
||||
setActiveId(null);
|
||||
setMessages([]);
|
||||
@@ -637,7 +680,9 @@ export function App() {
|
||||
const m = await api.models();
|
||||
setModels(m.models);
|
||||
setModel((cur) =>
|
||||
cur && m.models.some((x) => x.id === cur) ? cur : (m.models[0]?.id ?? ""),
|
||||
cur && m.models.some((x) => x.id === cur)
|
||||
? cur
|
||||
: pickModel(m.models, user ? getSavedModel(user.username) : null),
|
||||
);
|
||||
} catch {
|
||||
// ignore (session errors reload the page via api layer)
|
||||
@@ -715,12 +760,15 @@ export function App() {
|
||||
user={user}
|
||||
conv={active}
|
||||
messages={messages}
|
||||
streaming={streaming}
|
||||
sending={sending}
|
||||
// Streaming / thinking / sending UI lives only in the conversation
|
||||
// being generated for — anywhere else renders clean and stays usable.
|
||||
streaming={sendingFor === activeId ? streaming : ""}
|
||||
sending={sending && sendingFor === activeId}
|
||||
models={models}
|
||||
model={model}
|
||||
onModelChange={(id) => {
|
||||
setModel(id);
|
||||
setSavedModel(user.username, id);
|
||||
pushToast(t("toast.modelChanged", { label: models.find((m) => m.id === id)?.label ?? id }), {
|
||||
icon: "model",
|
||||
tag: "model",
|
||||
@@ -741,7 +789,7 @@ export function App() {
|
||||
setAttachments((prev) => prev.filter((a) => a.id !== id))
|
||||
}
|
||||
onPickFile={handlePickFile}
|
||||
failedIndex={failed?.index ?? null}
|
||||
failedIndex={failed && failed.convId === activeId ? failed.index : null}
|
||||
retryCount={retryCount}
|
||||
maxRetries={MAX_RETRIES}
|
||||
onRetry={readOnly ? undefined : retryFailed}
|
||||
|
||||
@@ -109,6 +109,7 @@ const en = {
|
||||
"chat.greet": "How can I help, {name}?",
|
||||
"chat.greetSub": "Pick a model, then type below. Markdown is supported.",
|
||||
"chat.thinking": "Thinking…",
|
||||
"chat.busyOther": "A reply is still generating in another chat — please wait.",
|
||||
"chat.placeholder": "Message KisAssistant…",
|
||||
"chat.send": "Send",
|
||||
"chat.attachTip": "Attach a file",
|
||||
@@ -690,6 +691,7 @@ const es: Dict = {
|
||||
"chat.greet": "¿En qué te ayudo, {name}?",
|
||||
"chat.greetSub": "Elige un modelo y escribe abajo. Markdown soportado.",
|
||||
"chat.thinking": "Pensando…",
|
||||
"chat.busyOther": "A reply is still generating in another chat — please wait.",
|
||||
"chat.placeholder": "Escribe a KisAssistant…",
|
||||
"chat.send": "Enviar",
|
||||
"chat.attachTip": "Adjuntar un archivo",
|
||||
@@ -1262,6 +1264,7 @@ const fr: Dict = {
|
||||
"chat.greetSub":
|
||||
"Choisissez un modèle, puis écrivez ci-dessous. Markdown pris en charge.",
|
||||
"chat.thinking": "Réflexion…",
|
||||
"chat.busyOther": "A reply is still generating in another chat — please wait.",
|
||||
"chat.placeholder": "Écrivez à KisAssistant…",
|
||||
"chat.send": "Envoyer",
|
||||
"chat.attachTip": "Joindre un fichier",
|
||||
@@ -1832,6 +1835,7 @@ const ru: Dict = {
|
||||
"chat.greet": "Чем помочь, {name}?",
|
||||
"chat.greetSub": "Выберите модель и пишите ниже. Поддерживается Markdown.",
|
||||
"chat.thinking": "Думаю…",
|
||||
"chat.busyOther": "A reply is still generating in another chat — please wait.",
|
||||
"chat.placeholder": "Сообщение KisAssistant…",
|
||||
"chat.send": "Отправить",
|
||||
"chat.attachTip": "Прикрепить файл",
|
||||
@@ -2390,6 +2394,7 @@ const it: Dict = {
|
||||
"chat.greet": "Come posso aiutare, {name}?",
|
||||
"chat.greetSub": "Scegli un modello e scrivi qui sotto. Markdown supportato.",
|
||||
"chat.thinking": "Penso…",
|
||||
"chat.busyOther": "A reply is still generating in another chat — please wait.",
|
||||
"chat.placeholder": "Scrivi a KisAssistant…",
|
||||
"chat.send": "Invia",
|
||||
"chat.attachTip": "Allega un file",
|
||||
@@ -2954,6 +2959,7 @@ const pt: Dict = {
|
||||
"chat.greet": "Como posso ajudar, {name}?",
|
||||
"chat.greetSub": "Escolhe um modelo e escreve abaixo. Markdown suportado.",
|
||||
"chat.thinking": "A pensar…",
|
||||
"chat.busyOther": "A reply is still generating in another chat — please wait.",
|
||||
"chat.placeholder": "Escreve ao KisAssistant…",
|
||||
"chat.send": "Enviar",
|
||||
"chat.attachTip": "Anexar ficheiro",
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Browser-local UI persistence (no server round-trip, per browser profile).
|
||||
*
|
||||
* - Selected model per account (`ka_model:<username>`): restored on boot when
|
||||
* still offered, otherwise the first available model wins.
|
||||
* - Failed model attempts (`ka_failed_v1`): the error bubble + retry state
|
||||
* survive a refresh. The fake error text is NEVER stored server-side, so
|
||||
* model history stays clean — the bubble is re-created locally on load and
|
||||
* filtered out of every outgoing payload.
|
||||
*/
|
||||
|
||||
export interface FailedEntry {
|
||||
/** Position of the error bubble in the message list (trailing). */
|
||||
index: number;
|
||||
retryCount: number;
|
||||
at: number;
|
||||
}
|
||||
|
||||
const FAILED_KEY = "ka_failed_v1";
|
||||
const FAILED_MAX_AGE_MS = 30 * 24 * 3600_000;
|
||||
|
||||
function readFailedMap(): Record<string, FailedEntry> {
|
||||
try {
|
||||
const raw = localStorage.getItem(FAILED_KEY);
|
||||
if (!raw) return {};
|
||||
const obj = JSON.parse(raw) as Record<string, unknown>;
|
||||
const out: Record<string, FailedEntry> = {};
|
||||
const now = Date.now();
|
||||
for (const [k, v] of Object.entries(obj ?? {})) {
|
||||
const e = v as FailedEntry;
|
||||
if (
|
||||
v !== null &&
|
||||
typeof v === "object" &&
|
||||
Number.isInteger(e.index) &&
|
||||
e.index >= 0 &&
|
||||
typeof e.retryCount === "number" &&
|
||||
typeof e.at === "number" &&
|
||||
now - e.at < FAILED_MAX_AGE_MS
|
||||
) {
|
||||
out[k] = { index: e.index, retryCount: e.retryCount, at: e.at };
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeFailedMap(map: Record<string, FailedEntry>): void {
|
||||
try {
|
||||
localStorage.setItem(FAILED_KEY, JSON.stringify(map));
|
||||
} catch {
|
||||
// Private mode / quota — retry simply won't survive a refresh.
|
||||
}
|
||||
}
|
||||
|
||||
export function getFailedEntry(convId: string): FailedEntry | null {
|
||||
return readFailedMap()[convId] ?? null;
|
||||
}
|
||||
|
||||
export function setFailedEntry(convId: string, entry: FailedEntry): void {
|
||||
const map = readFailedMap();
|
||||
map[convId] = entry;
|
||||
writeFailedMap(map);
|
||||
}
|
||||
|
||||
export function clearFailedEntry(convId: string): void {
|
||||
const map = readFailedMap();
|
||||
if (map[convId] !== undefined) {
|
||||
delete map[convId];
|
||||
writeFailedMap(map);
|
||||
}
|
||||
}
|
||||
|
||||
function modelKey(username: string): string {
|
||||
return `ka_model:${username.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function getSavedModel(username: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(modelKey(username));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setSavedModel(username: string, id: string): void {
|
||||
try {
|
||||
localStorage.setItem(modelKey(username), id);
|
||||
} catch {
|
||||
// ignore — falls back to first model next boot
|
||||
}
|
||||
}
|
||||
|
||||
/** Saved pick when still offered, otherwise the first available model. */
|
||||
export function pickModel(available: { id: string }[], saved: string | null): string {
|
||||
if (saved && available.some((m) => m.id === saved)) return saved;
|
||||
return available[0]?.id ?? "";
|
||||
}
|
||||
Reference in New Issue
Block a user