fix(client): 6 desktop bugs reported in real-world testing

#1 — Untitled session can't be deleted
   src/server/services/sessionService.ts:deleteSession is now idempotent.
   Previously placeholder sessions whose JSONL file hadn't been flushed
   yet would 404 the delete and stay stuck in the sidebar list. Now we
   succeed silently if the file's gone (and treat ENOENT during unlink
   the same way), matching how the frontend already optimistically
   removes the row.

#2 — Run button turns red 「stop」 on idle when switching model
   desktop/src/components/chat/ChatInput.tsx — gate isActive on
   hasMessages. ModelSelector / runtime config changes briefly flip
   chatState off-idle (CLI reconnect / startup). Without messages
   there's nothing to stop, so the button should stay disabled
   gradient, not turn into a red stop affordance.

#3 — Draft input bleeds across session switches
   desktop/src/components/chat/ChatInput.tsx — ChatInput is mounted
   once at app shell level; switching tabs doesn't re-mount it, so
   the local `input` useState carried over. Add a useEffect keyed on
   activeTabId that resets input + attachments + open menus + filter
   buffers. composerPrefill path (rewind) keeps owning its own reset
   via the existing prefill effect.

#4 — Close (×) minimized to tray instead of quitting
   desktop/src-tauri/src/lib.rs — drop the prevent_close + hide
   pathway on the main window's CloseRequested. Close now actually
   quits; users who want to keep the app running can minimize via
   the existing window controls. Tray icon stays available for
   re-open + explicit quit.

#5 — Tray menu hardcoded "Claude Code Haha"
   desktop/src-tauri/src/lib.rs — rename tray menu items, tray
   tooltip, and macOS app submenu to "Heicode" / "显示 Heicode" /
   "退出 Heicode" / "关于 Heicode".

#6 — Skills page silently empty when one source crashes
   src/server/api/skills.ts:listSkills uses Promise.allSettled so a
   single failed source (user / project / plugin) returns a partial
   list + structured errors[] instead of tanking the whole response.
   desktop/src/api/skills.ts + stores/skillStore.ts thread the
   errors through; SkillList only shows the hard-error wall when
   skills.length === 0.

Verification:
  bunx tsc -b --noEmit (desktop)        — clean
  bunx tsc --noEmit (cc-haha root)      — clean
  cargo check (src-tauri)               — clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 13:48:40 +08:00
co-authored by Claude Opus 4.7
parent b845a08840
commit 30056136ea
7 changed files with 91 additions and 23 deletions
+13 -11
View File
@@ -167,13 +167,13 @@ fn show_main_window(app: &AppHandle) {
fn setup_system_tray(app: &mut tauri::App) -> tauri::Result<()> {
let menu = MenuBuilder::new(app)
.text(TRAY_SHOW_ID, "Show Claude Code Haha")
.text(TRAY_SHOW_ID, "显示 Heicode")
.separator()
.text(TRAY_QUIT_ID, "Quit Claude Code Haha")
.text(TRAY_QUIT_ID, "退出 Heicode")
.build()?;
let mut tray = TrayIconBuilder::with_id("main-tray")
.tooltip("Claude Code Haha")
.tooltip("Heicode")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id().as_ref() {
@@ -986,12 +986,12 @@ pub fn run() {
let builder = builder
.menu(|app| {
let about_item =
MenuItemBuilder::with_id("nav_about", "关于 Claude Code Haha").build(app)?;
MenuItemBuilder::with_id("nav_about", "关于 Heicode").build(app)?;
let settings_item = MenuItemBuilder::with_id("nav_settings", "设置...")
.accelerator("CmdOrCtrl+,")
.build(app)?;
let app_submenu = SubmenuBuilder::new(app, "Claude Code Haha")
let app_submenu = SubmenuBuilder::new(app, "Heicode")
.item(&about_item)
.separator()
.item(&settings_item)
@@ -1074,15 +1074,17 @@ pub fn run() {
.expect("error while building tauri application");
app.run(|app_handle, event| match event {
// Close (×) on the main window now means "really quit" rather than
// "minimize to tray". Mark the app as quitting so any cleanup paths
// that check is_quitting do the right thing; the tray icon stays
// available for users who explicitly choose 「显示 Heicode」/「退出 Heicode」
// from the tray menu.
RunEvent::WindowEvent {
label,
event: WindowEvent::CloseRequested { api, .. },
event: WindowEvent::CloseRequested { .. },
..
} if should_hide_to_tray(app_handle, &label) => {
api.prevent_close();
if let Some(window) = app_handle.get_webview_window(&label) {
let _ = window.hide();
}
} if label == MAIN_WINDOW_LABEL => {
mark_app_quitting(app_handle);
}
#[cfg(target_os = "macos")]
RunEvent::Reopen {
+7 -1
View File
@@ -4,7 +4,13 @@ import type { SkillMeta, SkillDetail } from '../types/skill'
export const skillsApi = {
list: (cwd?: string) => {
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
return api.get<{ skills: SkillMeta[] }>(`/api/skills${query}`, { timeout: 120_000 })
return api.get<{
skills: SkillMeta[]
// Set when one of the skill sources (user / project / plugin) failed
// but at least one succeeded. Server returns whatever it could
// collect. Frontend can show a soft warning instead of a hard error.
errors?: Array<{ source: string; message: string }>
}>(`/api/skills${query}`, { timeout: 120_000 })
},
detail: (source: string, name: string, cwd?: string) => {
@@ -69,7 +69,12 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
const hasMessages = useChatStore((s) => activeTabId ? (s.sessions[activeTabId]?.messages?.length ?? 0) > 0 : false)
const isMemberSession = !!memberInfo
const isActive = chatState !== 'idle'
// Bug fix: ModelSelector / runtime config changes briefly flip
// chatState off-idle (CLI reconnect / startup), which used to make the
// run button render as a red 「stop」 even on an empty session. Gate by
// hasMessages so 「stop」 only appears once we actually have something
// running for the user to stop.
const isActive = chatState !== 'idle' && hasMessages
const isWorkspaceMissing = activeSession?.workDirExists === false
const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && attachments.length > 0))
const isHeroComposer = variant === 'hero' && !isMemberSession
@@ -122,6 +127,22 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
sessionsApi.getGitInfo(activeTabId).then(setGitInfo).catch(() => setGitInfo(null))
}, [activeTabId, isMemberSession])
// Bug fix: input draft was bleeding across session switches because
// `input` is local component state and ChatInput is mounted once at app
// shell level (not per-session). Reset whenever the user switches tabs;
// do NOT clear when composerPrefill is about to set things — that path
// owns its own reset via the prefill effect above.
useEffect(() => {
setInput('')
setAttachments([])
setPlusMenuOpen(false)
setSlashMenuOpen(false)
setFileSearchOpen(false)
setSlashFilter('')
setAtFilter('')
setAtCursorPos(-1)
}, [activeTabId])
useEffect(() => {
if (!isMemberSession) return
setAttachments([])
@@ -66,7 +66,8 @@ export function SkillList() {
)
}
if (error) {
// Hard error: failed to fetch list at all.
if (error && skills.length === 0) {
return <div className="text-sm text-[var(--color-error)] py-4">{error}</div>
}
+9 -2
View File
@@ -33,8 +33,15 @@ export const useSkillStore = create<SkillStore>((set) => ({
fetchSkills: async (cwd) => {
set({ isLoading: true, error: null })
try {
const { skills } = await skillsApi.list(cwd)
set({ skills, isLoading: false })
const { skills, errors } = await skillsApi.list(cwd)
// Soft error: server returned data but some sources failed (e.g.
// plugin scanner crashed). Surface a one-line summary so the user
// doesn't think the empty/short list is final.
const softError =
errors && errors.length > 0
? errors.map((e) => `${e.source}: ${e.message}`).join('; ')
: null
set({ skills, isLoading: false, error: softError })
} catch (err) {
set({
error: err instanceof Error ? err.message : String(err),
+27 -3
View File
@@ -386,15 +386,39 @@ export async function handleSkillsApi(
async function listSkills(url: URL): Promise<Response> {
const cwd = getRequestedCwd(url)
const [userSkills, projectSkills, pluginSkills] = await Promise.all([
// Each source can fail independently (e.g. plugin scanner crashes on a
// single bad SKILL.md, getProjectSkillsDirs walks into a path that the
// user no longer has access to, etc). Previously a single rejected
// promise would tank the whole list and the frontend just saw a generic
// 500. Use allSettled so partial failure still returns the working
// sources, and log the failed ones so we can debug them.
const [userResult, projectResult, pluginResult] = await Promise.allSettled([
collectSkillsFromRoots([getUserSkillsDir()], 'user'),
collectSkillsFromRoots(getProjectSkillsDirs(cwd), 'project'),
collectPluginSkills(),
])
const skills = [...userSkills, ...projectSkills, ...pluginSkills]
const skills: SkillMeta[] = []
const errors: Array<{ source: string; message: string }> = []
const consume = (
source: 'user' | 'project' | 'plugin',
result: PromiseSettledResult<SkillMeta[]>,
) => {
if (result.status === 'fulfilled') {
skills.push(...result.value)
} else {
const message =
result.reason instanceof Error ? result.reason.message : String(result.reason)
console.error(`[skills] ${source} source failed: ${message}`)
errors.push({ source, message })
}
}
consume('user', userResult)
consume('project', projectResult)
consume('plugin', pluginResult)
skills.sort((a, b) => a.name.localeCompare(b.name))
return Response.json({ skills })
return Response.json({ skills, errors: errors.length > 0 ? errors : undefined })
}
async function getSkillDetail(url: URL): Promise<Response> {
+11 -4
View File
@@ -1169,11 +1169,18 @@ export class SessionService {
*/
async deleteSession(sessionId: string): Promise<void> {
const found = await this.findSessionFile(sessionId)
if (!found) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
// Idempotent: if the JSONL file isn't on disk we treat the session as
// already gone. Untitled / placeholder sessions sometimes never get
// their file flushed (e.g. user clicks delete from the sidebar before
// the first user message round-trip persists), and surfacing a 404 in
// that case leaves the row stuck in the sidebar list. Just succeed.
if (!found) return
try {
await fs.unlink(found.filePath)
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
if (code !== 'ENOENT') throw err
}
}
/**