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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
await fs.unlink(found.filePath)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user