[appimage] UI-Polish: Icon, Stop-Button dezent, Chat-Queue, Update-Safety
All checks were successful
Build AppImage / build (push) Successful in 6m37s

- Neues Icon-Set (SVG-Quelle + gen-icon.sh): 32/64/128/256/512+@2x in depth=8
  (Tauri-Tray erwartet 8-bit-RGBA, depth=16 crashte den Tray-Setup)
- StopButton: Icon-only (⏹), Position Titlebar rechts, nur sichtbar wenn
  isProcessing aktiv. Kein full-width roter Balken im Footer mehr.
- .footer.active-Farbwechsel entfernt — Footer bleibt neutral
- Version-Badge in der Titlebar (v<APP_VERSION>)
- Chat-Input-Queue: Single-Slot-Puffer. Beim Senden waehrend Processing wird
  die Nachricht gepuffert, Pill "Nachricht wartet..." erscheint, nach Ende
  der aktuellen Antwort wird automatisch abgeschickt.
- Stop verwirft den gepufferten Slot (bewusster Abbruch).
- apply_update: ELF-Header-Smoke-Test vor Rename. Kaputter Download oder
  falsche Architektur liefert Fehlerdialog statt zerschossene Installation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-20 11:52:43 +02:00
parent 71e84067f8
commit 29cce7fbd8
15 changed files with 267 additions and 41 deletions

48
scripts/gen-icon.sh Normal file
View file

@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Erzeugt alle Icon-Groessen fuer Tauri aus scripts/icon.svg.
# Braucht ImageMagick (auf Nix: via nix-shell automatisch verfuegbar).
#
# Aufruf: ./scripts/gen-icon.sh
# Ergebnis: src-tauri/icons/{icon.png, 32x32.png, 128x128.png, 128x128@2x.png}
set -euo pipefail
cd "$(dirname "$0")/.."
SVG="scripts/icon.svg"
OUT="src-tauri/icons"
if [ ! -f "$SVG" ]; then
echo "$SVG nicht gefunden" >&2; exit 1
fi
mkdir -p "$OUT"
# ImageMagick aus Nix ziehen falls nicht vorhanden
if ! command -v magick >/dev/null && ! command -v convert >/dev/null; then
echo " ImageMagick fehlt — ziehe aus nixpkgs"
IM=$(nix-build '<nixpkgs>' -A imagemagick --no-out-link 2>/dev/null || true)
[ -n "$IM" ] && export PATH="$IM/bin:$PATH" || { echo "❌ ImageMagick nicht bezogen"; exit 1; }
fi
# Moderne ImageMagick nutzt `magick`, aeltere `convert`
if command -v magick >/dev/null; then IM_CMD="magick"; else IM_CMD="convert"; fi
echo "Generiere Icons aus $SVG mit $IM_CMD..."
# WICHTIG: -depth 8 erzwingen — sonst erzeugt ImageMagick aus hoher SVG-Density
# 16-bit-RGBA-PNGs. Tauris Tray-Icon-Crate erwartet 8-bit RGBA (4 Byte/Pixel).
for size in 32 64 128 256 512; do
$IM_CMD -background none -density 400 "$SVG" -resize "${size}x${size}" -depth 8 "$OUT/${size}x${size}.png"
echo "$OUT/${size}x${size}.png"
done
# Retina-Variante (Tauri-Konvention)
$IM_CMD -background none -density 400 "$SVG" -resize 256x256 -depth 8 "$OUT/128x128@2x.png"
echo "$OUT/128x128@2x.png"
# Haupt-Icon
cp "$OUT/512x512.png" "$OUT/icon.png"
echo "$OUT/icon.png (Haupt-Icon 512x512)"
echo
echo "✅ Fertig. Icons in $OUT/:"
ls -l "$OUT"/*.png

44
scripts/icon.svg Normal file
View file

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<!-- Claude Desktop Icon — AWL-Farbpalette -->
<defs>
<radialGradient id="bg" cx="50%" cy="35%" r="80%">
<stop offset="0%" stop-color="#2a2d3e"/>
<stop offset="100%" stop-color="#151722"/>
</radialGradient>
<linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ef4a69"/>
<stop offset="100%" stop-color="#c93154"/>
</linearGradient>
<filter id="glow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="8"/>
<feColorMatrix values="0 0 0 0 0.92 0 0 0 0 0.27 0 0 0 0 0.41 0 0 0 0.55 0"/>
</filter>
</defs>
<!-- Hintergrund mit abgerundeten Ecken (iOS/Android-Style) -->
<rect x="0" y="0" width="512" height="512" rx="112" ry="112" fill="url(#bg)"/>
<!-- Subtiler Akzent-Ring -->
<circle cx="256" cy="256" r="180" fill="none" stroke="rgba(233,69,96,0.18)" stroke-width="3"/>
<!-- Hauptmotiv: stilisiertes C als Chat/Claude-Symbol -->
<g filter="url(#glow)">
<path d="M 346 180
A 100 100 0 1 0 346 332
L 346 286
A 60 60 0 1 1 346 226
Z"
fill="url(#accent)"/>
</g>
<!-- Schatten-Ring zur Tiefenwirkung -->
<circle cx="256" cy="256" r="100"
fill="none"
stroke="rgba(0,0,0,0.4)"
stroke-width="2"
transform="rotate(-15 256 256)"/>
<!-- Kleiner Chat-Punkt (Dialog-Andeutung) -->
<circle cx="200" cy="256" r="10" fill="#ffffff" opacity="0.92"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
src-tauri/icons/256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
src-tauri/icons/512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 B

After

Width:  |  Height:  |  Size: 73 KiB

View file

@ -269,6 +269,10 @@ pub async fn download_update(
/// 2. Nix-Wrapper-Modus → `$CLAUDE_DESKTOP_NIX_WRAPPER=1` + `$CLAUDE_DESKTOP_BIN` zeigt /// 2. Nix-Wrapper-Modus → `$CLAUDE_DESKTOP_NIX_WRAPPER=1` + `$CLAUDE_DESKTOP_BIN` zeigt
/// auf ~/.local/share/claude-desktop/bin/claude-desktop (writable, siehe nix/default.nix) /// auf ~/.local/share/claude-desktop/bin/claude-desktop (writable, siehe nix/default.nix)
/// 3. Entwicklungs-Build → Fehlerhinweis mit Build-Anleitung /// 3. Entwicklungs-Build → Fehlerhinweis mit Build-Anleitung
///
/// Vor dem Rename wird ein Smoke-Test durchgeführt: Die heruntergeladene Datei
/// muss ein valides ELF-Binary sein (Magic-Bytes 0x7F 'ELF'). Das fängt korrupte
/// Downloads und falsche Architekturen ab, bevor das funktionsfähige Binary ersetzt wird.
#[tauri::command] #[tauri::command]
pub async fn apply_update(app: AppHandle, update_path: PathBuf) -> Result<(), String> { pub async fn apply_update(app: AppHandle, update_path: PathBuf) -> Result<(), String> {
// Modus bestimmen: AppImage > Nix-Wrapper > Dev // Modus bestimmen: AppImage > Nix-Wrapper > Dev
@ -289,7 +293,34 @@ pub async fn apply_update(app: AppHandle, update_path: PathBuf) -> Result<(), St
.to_string() .to_string()
})?; })?;
// Backup-Pfad: gleiches Verzeichnis, Suffix .backup // === Smoke-Test: Ist die neue Datei ein valides ELF-Binary? ===
// ELF-Magic: 0x7F 'E' 'L' 'F' (gilt auch fuer AppImages, die self-extracting ELFs sind)
let meta = std::fs::metadata(&update_path)
.map_err(|e| format!("Update-Datei nicht zugreifbar: {}", e))?;
if !meta.is_file() {
return Err("Update-Datei ist keine regulaere Datei — Abbruch, bestehende Installation unveraendert.".into());
}
if meta.len() < 4 {
return Err(format!(
"Update-Datei ist zu klein ({} Bytes) — vermutlich abgebrochener Download. Bestehende Installation unveraendert.",
meta.len()
));
}
{
use std::io::Read;
let mut magic = [0u8; 4];
std::fs::File::open(&update_path)
.and_then(|mut f| f.read_exact(&mut magic))
.map_err(|e| format!("Update-Datei nicht lesbar: {} — bestehende Installation unveraendert.", e))?;
if magic != [0x7F, 0x45, 0x4C, 0x46] {
return Err(format!(
"Update-Datei ist kein ausfuehrbares ELF-Binary (Magic: {:02X} {:02X} {:02X} {:02X}). Bestehende Installation unveraendert.",
magic[0], magic[1], magic[2], magic[3]
));
}
}
// === Backup + Rename ===
let backup_path = { let backup_path = {
let mut p = target.clone(); let mut p = target.clone();
let file_name = target let file_name = target
@ -306,7 +337,7 @@ pub async fn apply_update(app: AppHandle, update_path: PathBuf) -> Result<(), St
std::fs::rename(&update_path, &target).map_err(|e| { std::fs::rename(&update_path, &target).map_err(|e| {
// Rollback // Rollback
std::fs::rename(&backup_path, &target).ok(); std::fs::rename(&backup_path, &target).ok();
format!("Update-Installation fehlgeschlagen ({}): {}", mode_label, e) format!("Update-Installation fehlgeschlagen ({}): {} — Backup wiederhergestellt.", mode_label, e)
})?; })?;
std::fs::remove_file(&backup_path).ok(); std::fs::remove_file(&backup_path).ok();

View file

@ -33,6 +33,9 @@
"active": true, "active": true,
"targets": ["appimage", "deb"], "targets": ["appimage", "deb"],
"icon": [ "icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.png" "icons/icon.png"
] ]
}, },

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { emit } from '@tauri-apps/api/event'; import { emit } from '@tauri-apps/api/event';
import { messages, currentInput, isProcessing, addMessage, currentSessionId, messageToDb, type Message } from '$lib/stores/app'; import { messages, currentInput, isProcessing, addMessage, currentSessionId, messageToDb, queuedMessage, type Message } from '$lib/stores/app';
import { marked, type Tokens } from 'marked'; import { marked, type Tokens } from 'marked';
import { tick, onDestroy, onMount } from 'svelte'; import { tick, onDestroy, onMount } from 'svelte';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
@ -386,15 +386,49 @@
onMount(() => { onMount(() => {
window.addEventListener('keydown', handleGlobalKeydown); window.addEventListener('keydown', handleGlobalKeydown);
// Queue-Auto-Dispatch: sobald isProcessing von true auf false wechselt
// und ein Puffer vorhanden ist, wird er automatisch abgeschickt.
let lastProcessing = false;
const unsubProcessing = isProcessing.subscribe((val) => {
if (lastProcessing && !val) {
const queued = get(queuedMessage);
if (queued) {
$queuedMessage = null;
dispatchMessage(queued).catch((e) => console.error('Queue-Dispatch fehlgeschlagen:', e));
}
}
lastProcessing = val;
});
return () => { return () => {
window.removeEventListener('keydown', handleGlobalKeydown); window.removeEventListener('keydown', handleGlobalKeydown);
unsubProcessing();
}; };
}); });
function cancelQueued() {
$queuedMessage = null;
}
async function sendMessage() { async function sendMessage() {
const text = $currentInput.trim(); const text = $currentInput.trim();
if (!text) return; if (!text) return;
// Waehrend Claude antwortet: Single-Slot-Puffer statt Doppel-Send.
// Der Subscriber weiter unten sendet den Puffer automatisch ab, sobald
// isProcessing von true auf false wechselt.
if ($isProcessing) {
$queuedMessage = text;
$currentInput = '';
return;
}
await dispatchMessage(text);
}
// Den eigentlichen Send-Flow ausgelagert, damit er auch fuer die Queue genutzt wird.
async function dispatchMessage(text: string) {
// Auto-Session erstellen falls keine aktiv // Auto-Session erstellen falls keine aktiv
let sessionId = get(currentSessionId); let sessionId = get(currentSessionId);
if (!sessionId) { if (!sessionId) {
@ -742,11 +776,18 @@
<span class="transcript-text">{liveTranscript}</span> <span class="transcript-text">{liveTranscript}</span>
</div> </div>
{/if} {/if}
{#if $queuedMessage}
<div class="queued-pill" title="Wird gesendet sobald Claude die aktuelle Antwort fertig hat">
<span class="queued-icon">📬</span>
<span class="queued-text">Nachricht wartet: „{$queuedMessage.slice(0, 80)}{$queuedMessage.length > 80 ? '…' : ''}"</span>
<button class="queued-cancel" onclick={cancelQueued} aria-label="Wartende Nachricht verwerfen"></button>
</div>
{/if}
<textarea <textarea
bind:this={inputTextarea} bind:this={inputTextarea}
bind:value={$currentInput} bind:value={$currentInput}
onkeydown={handleKeydown} onkeydown={handleKeydown}
placeholder="Nachricht eingeben... (Ctrl+K = Focus, Ctrl+Enter = Senden)" placeholder={$isProcessing ? 'Nachricht wird nach aktueller Antwort gesendet...' : 'Nachricht eingeben... (Ctrl+K = Focus, Ctrl+Enter = Senden)'}
disabled={isRecording} disabled={isRecording}
rows="3" rows="3"
></textarea> ></textarea>
@ -1409,6 +1450,44 @@
color: #ef4444; color: #ef4444;
} }
.queued-pill {
display: flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
margin-bottom: var(--spacing-xs);
background: rgba(96, 165, 250, 0.08);
border: 1px solid rgba(96, 165, 250, 0.25);
border-radius: var(--radius-sm);
font-size: 0.75rem;
color: #93c5fd;
}
.queued-icon {
flex-shrink: 0;
}
.queued-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.queued-cancel {
background: none;
border: none;
color: inherit;
cursor: pointer;
font-size: 0.9rem;
padding: 0 4px;
opacity: 0.7;
}
.queued-cancel:hover {
opacity: 1;
}
.transcript-icon { .transcript-icon {
animation: pulse 1s ease-in-out infinite; animation: pulse 1s ease-in-out infinite;
} }

View file

@ -17,56 +17,45 @@
class:disabled class:disabled
on:click={handleClick} on:click={handleClick}
{disabled} {disabled}
aria-label="Alle Agents sofort stoppen" title="Stopp (Esc) — beendet aktive Agenten, Session bleibt erhalten"
aria-label="Aktive Agents stoppen"
> >
<span class="stop-icon"></span> <span class="stop-icon"></span>
<span class="stop-text">STOPP — Alles sofort abbrechen</span>
<span class="stop-hint">(Escape)</span>
</button> </button>
<style> <style>
.stop-button { .stop-button {
width: 100%; display: inline-flex;
display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: var(--spacing-xs); width: 24px;
padding: var(--spacing-xs) var(--spacing-sm); height: 24px;
background: #c53030; padding: 0;
color: rgba(255, 255, 255, 0.85); background: transparent;
font-size: 0.8rem; color: #e94560;
font-weight: 600; font-size: 0.95rem;
letter-spacing: 0.02em; border: 1px solid transparent;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
border: 1px solid rgba(197, 48, 48, 0.6);
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: background 0.15s ease, border-color 0.15s ease;
} }
.stop-button:not(.disabled):hover { .stop-button:not(.disabled):hover {
background: #b52828; background: rgba(233, 69, 96, 0.12);
color: white; border-color: rgba(233, 69, 96, 0.35);
border-color: rgba(197, 48, 48, 0.8);
} }
.stop-button:not(.disabled):active { .stop-button:not(.disabled):active {
background: #a02020; background: rgba(233, 69, 96, 0.22);
} }
.stop-button.disabled { .stop-button.disabled {
background: var(--bg-tertiary);
border-color: var(--bg-tertiary);
color: var(--text-secondary); color: var(--text-secondary);
opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
} }
.stop-icon { .stop-icon {
font-size: 0.875rem; line-height: 1;
}
.stop-hint {
font-size: 0.65rem;
font-weight: 400;
opacity: 0.65;
} }
</style> </style>

View file

@ -58,6 +58,11 @@ export const selectedAgentId = writable<string | null>(null);
export const currentModel = writable(''); export const currentModel = writable('');
export const currentSessionId = writable<string | null>(null); export const currentSessionId = writable<string | null>(null);
// Single-Slot-Puffer: wenn eine Nachricht abgeschickt wird waehrend Claude noch
// antwortet, wird sie hier gehalten und nach Ende der aktuellen Antwort
// automatisch gesendet. Neuere Inhalte ueberschreiben einen wartenden Slot.
export const queuedMessage = writable<string | null>(null);
// Agent-Modus für Multi-Agent-Architektur // Agent-Modus für Multi-Agent-Architektur
export type AgentMode = 'solo' | 'handlanger' | 'experten' | 'auto'; export type AgentMode = 'solo' | 'handlanger' | 'experten' | 'auto';
export const agentMode = writable<AgentMode>('solo'); export const agentMode = writable<AgentMode>('solo');

View file

@ -2,7 +2,7 @@
import '../app.css'; import '../app.css';
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { isProcessing, agentCount, currentModel, sessionStats, contextPercent, contextUsage, initEventListeners, cleanupEventListeners, currentSessionId, setMessagesFromDb, stickyContextInfo, agentMode, type DbMessage, type StickyContextInfo, type AgentMode } from '$lib/stores'; import { isProcessing, agentCount, currentModel, sessionStats, contextPercent, contextUsage, initEventListeners, cleanupEventListeners, currentSessionId, setMessagesFromDb, stickyContextInfo, agentMode, queuedMessage, type DbMessage, type StickyContextInfo, type AgentMode } from '$lib/stores';
import StopButton from '$lib/components/StopButton.svelte'; import StopButton from '$lib/components/StopButton.svelte';
import UpdateDialog from '$lib/components/UpdateDialog.svelte'; import UpdateDialog from '$lib/components/UpdateDialog.svelte';
@ -33,9 +33,19 @@
rules_count: number; rules_count: number;
} }
let appVersion = '';
onMount(async () => { onMount(async () => {
await initEventListeners(); await initEventListeners();
// App-Version aus Rust holen (wird von der Pipeline als APP_VERSION gesetzt)
try {
appVersion = await invoke('get_current_version');
} catch (err) {
console.warn('App-Version konnte nicht geladen werden:', err);
appVersion = 'dev';
}
// Aktuelles Modell aus Settings laden // Aktuelles Modell aus Settings laden
try { try {
const model: string = await invoke('get_current_model'); const model: string = await invoke('get_current_model');
@ -112,6 +122,9 @@
} catch (err) { } catch (err) {
console.error('Fehler beim Stoppen:', err); console.error('Fehler beim Stoppen:', err);
} }
// Wartende Nachricht verwerfen — der User hat bewusst abgebrochen,
// die Queue soll nicht automatisch nachfeuern.
$queuedMessage = null;
$isProcessing = false; $isProcessing = false;
} }
@ -152,6 +165,9 @@
{/if} {/if}
</div> </div>
<div class="titlebar-right"> <div class="titlebar-right">
{#if $isProcessing}
<StopButton on:click={handleStop} />
{/if}
<button <button
class="teach-btn" class="teach-btn"
title="Schulungsmodus (Präsentations-Fenster)" title="Schulungsmodus (Präsentations-Fenster)"
@ -162,6 +178,11 @@
{#if $currentModel} {#if $currentModel}
<span class="model-badge">{$currentModel.replace('claude-', '').replace(/-\d{8}$/, '').replace(/(\D)-(\d)/g, '$1 $2').replace(/(\d)-(\d)/g, '$1.$2').replace(/\b[a-z]/g, c => c.toUpperCase())}</span> <span class="model-badge">{$currentModel.replace('claude-', '').replace(/-\d{8}$/, '').replace(/(\D)-(\d)/g, '$1 $2').replace(/(\d)-(\d)/g, '$1.$2').replace(/\b[a-z]/g, c => c.toUpperCase())}</span>
{/if} {/if}
{#if appVersion}
<span class="version-badge" class:dev={appVersion === 'dev'} title="App-Version">
v{appVersion}
</span>
{/if}
</div> </div>
</header> </header>
@ -169,9 +190,8 @@
<slot /> <slot />
</main> </main>
<!-- Footer: STOPP + Stats --> <!-- Footer: nur Stats (Stop-Button ist in die Titlebar gewandert) -->
<footer class="footer" class:active={$isProcessing}> <footer class="footer">
<StopButton on:click={handleStop} disabled={!$isProcessing} />
<div class="footer-stats"> <div class="footer-stats">
{#if $stickyContextInfo?.loaded} {#if $stickyContextInfo?.loaded}
<span class="context-badge" title="Sticky Context: {$stickyContextInfo.entries} Einträge, ~{$stickyContextInfo.estimatedTokens} Token"> <span class="context-badge" title="Sticky Context: {$stickyContextInfo.entries} Einträge, ~{$stickyContextInfo.estimatedTokens} Token">
@ -267,6 +287,18 @@
font-weight: 600; font-weight: 600;
} }
.version-badge {
font-size: 0.6rem;
color: var(--text-secondary);
font-family: var(--font-mono);
opacity: 0.7;
}
.version-badge.dev {
opacity: 0.4;
font-style: italic;
}
.titlebar-right { .titlebar-right {
display: flex; display: flex;
gap: var(--spacing-xs); gap: var(--spacing-xs);
@ -286,18 +318,13 @@
.footer { .footer {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: var(--spacing-xs); justify-content: center;
padding: var(--spacing-xs) var(--spacing-md); padding: var(--spacing-xs) var(--spacing-md);
background: var(--bg-secondary); background: var(--bg-secondary);
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
.footer.active {
border-top: 2px solid var(--error);
}
.footer-stats { .footer-stats {
display: flex; display: flex;
gap: var(--spacing-sm); gap: var(--spacing-sm);