feat(desktop): 0.1.1 — UX fixes + multiSelect + clean About + version bump

Roll-up of tonight's desktop fixes shipped as 0.1.1:

- PermissionDialog: cap diff/command preview at max-h-360 so the
  allow/deny buttons stay above the fold on huge writes (user
  reported scrolling 900 lines to find the buttons).
- SessionTaskBar + cliTaskStore: hover row shows ✓ manual-close
  button for tasks the agent forgot to mark completed.
- AskUserQuestion: honor schema's `multiSelect: true` — toggle
  membership across options, switch round → square indicator,
  show "可多选" hint, join answers with comma.
- HeicodeLoginPage: removed dead `'official'` filter that blocked
  the typescript build (legacy provider id no longer in the union).
- Settings About: removed the third-party social-media block and
  unused openUrl helper.
- i18n: replaced misleading "GitHub Releases" wording with
  "Heicode 官方更新源" / "Heicode update source" — actual channel
  is Azure Blob (msi/updater/latest.json) per tauri.conf.json.
- release-desktop.mjs: az invocations now run with shell:true so
  the Windows `az.cmd` resolves; also uploads the manifest to
  blob (primary endpoint) as the script finishes.
- tauri.conf.json + package.json + Cargo.toml: version bumped to
  0.1.1.
- website/public/updater/latest.json: now reflects 0.1.1 + new
  signed NSIS URL (mirrored to blob by the release script).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 14:34:26 +08:00
co-authored by Claude Opus 4.7
parent 8d317b2eef
commit 50830c2393
14 changed files with 132 additions and 78 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "heicode-desktop",
"private": true,
"version": "0.1.0",
"version": "0.1.1",
"type": "module",
"scripts": {
"dev": "vite",
+13 -6
View File
@@ -86,7 +86,9 @@ function uploadBlob(localPath, remoteName) {
'--overwrite',
'--no-progress',
],
{ stdio: ['ignore', 'pipe', 'pipe'], env, encoding: 'utf8' },
// On Windows `az` is `az.cmd`. shell:true so node finds it on PATH
// without us having to detect the platform.
{ stdio: ['ignore', 'pipe', 'pipe'], env, encoding: 'utf8', shell: true },
)
if (result.status !== 0) {
console.error(result.stderr)
@@ -136,8 +138,13 @@ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
console.log(`\nWrote ${manifestPath}`)
console.log(`Version : ${version}`)
console.log(`Platforms: ${Object.keys(platforms).join(', ')}`)
console.log('\nNext: commit + push website to redeploy Azure SWA.')
console.log(` cd ${join(REPO_ROOT, 'website')}`)
console.log(` git add public/updater/latest.json`)
console.log(` git commit -m "release: desktop ${version}"`)
console.log(' git push')
// Also upload manifest to blob so the blob-hosted endpoint resolves
// without depending on the website SWA deploy chain. tauri.conf.json
// lists the blob endpoint first so this is the primary update channel.
console.log('\nUploading manifest to blob (primary endpoint) ...')
uploadBlob(manifestPath, 'updater/latest.json')
console.log(`Manifest now live at ${PUBLIC_HOST}/updater/latest.json`)
console.log('\nOptional: commit + push website to redeploy SWA fallback.')
console.log(` cd ${join(REPO_ROOT, 'website')} && git add public/updater/latest.json && git commit && git push`)
+1 -1
View File
@@ -1525,7 +1525,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "heicode-desktop"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"anyhow",
"portable-pty",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "heicode-desktop"
version = "0.1.0"
version = "0.1.1"
edition = "2021"
[lib]
+2 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri-schema-v2.json",
"productName": "HeiCode",
"version": "0.1.0",
"version": "0.1.1",
"identifier": "com.heicode.desktop",
"build": {
"frontendDist": "../dist",
@@ -33,6 +33,7 @@
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEI3Q0FBOUQ2MTgwRkM4NjQKUldSa3lBOFkxcW5LdDhLUFZnNUlEdmllY0ZudjFhb2VXTUpSWmpkUmM1cjdoNDFwRW1MZTN5Yi8K",
"endpoints": [
"https://heicodeblob.blob.core.windows.net/msi/updater/latest.json",
"https://ashy-dune-0e22d7b00.7.azurestaticapps.net/updater/latest.json"
],
"windows": {
@@ -13,12 +13,24 @@ type Question = {
question: string
header?: string
options?: QuestionOption[]
multiSelect?: boolean
}
type AskUserInput = {
questions?: Question[]
question?: string
options?: QuestionOption[]
multiSelect?: boolean
}
// One question's selection state. For multi-select questions this is the
// array of chosen labels; for single-select it's a single string. Indexed
// by the question's tab index in the parsed `questions` array.
type Selection = string | string[]
function selectionToArray(sel: Selection | undefined): string[] {
if (!sel) return []
return Array.isArray(sel) ? sel : [sel]
}
type Props = {
@@ -41,7 +53,11 @@ function parseInput(input: unknown): Question[] {
// Shape 2: { question: "...", options: [...] }
if (typeof obj.question === 'string') {
return [{ question: obj.question, options: obj.options }]
return [{
question: obj.question,
options: obj.options,
multiSelect: obj.multiSelect,
}]
}
return []
@@ -55,7 +71,7 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) {
const questions = parseInput(input)
const inputObject = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
const [activeTab, setActiveTab] = useState(0)
const [selections, setSelections] = useState<Record<number, string>>({})
const [selections, setSelections] = useState<Record<number, Selection>>({})
const [freeText, setFreeText] = useState('')
const [hasSubmitted, setHasSubmitted] = useState(false)
const composingRef = useRef(false)
@@ -78,18 +94,32 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) {
.filter((answer): answer is string => typeof answer === 'string' && answer.trim().length > 0)
.join(', ')
}
return freeText.trim() || Object.values(selections).join(', ')
return freeText.trim() || Object.values(selections).map((s) => selectionToArray(s).join(', ')).join(', ')
}, [freeText, questions, resultAnswers, selections])
const submitted = Object.keys(resultAnswers).length > 0 || hasSubmitted
const handleSelect = (qIndex: number, label: string) => {
if (submitted) return
const isMulti = !!questions[qIndex]?.multiSelect
setSelections((prev) => {
// Toggle: deselect if already selected
if (isMulti) {
const current = selectionToArray(prev[qIndex])
const next = current.includes(label)
? current.filter((x) => x !== label)
: [...current, label]
const out = { ...prev }
if (next.length === 0) {
delete out[qIndex]
} else {
out[qIndex] = next
}
return out
}
// Single-select: toggle off if already chosen, otherwise replace.
if (prev[qIndex] === label) {
const next = { ...prev }
delete next[qIndex]
return next
const out = { ...prev }
delete out[qIndex]
return out
}
return { ...prev, [qIndex]: label }
})
@@ -101,8 +131,8 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) {
const parts: string[] = []
for (let i = 0; i < questions.length; i++) {
const selected = selections[i]
if (selected) parts.push(selected)
const selected = selectionToArray(selections[i])
if (selected.length > 0) parts.push(selected.join(', '))
}
const response = freeText.trim() || parts.join('; ') || ''
if (!response) return
@@ -112,8 +142,9 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) {
const answers = questions.reduce<Record<string, string>>((acc, question, index) => {
if (freeText.trim()) {
acc[question.question] = freeText.trim()
} else if (selections[index]) {
acc[question.question] = selections[index]!
} else {
const sel = selectionToArray(selections[index])
if (sel.length > 0) acc[question.question] = sel.join(', ')
}
return acc
}, {})
@@ -128,7 +159,7 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) {
}
// All questions must be answered (via selection or free text) to enable submit
const allAnswered = freeText.trim().length > 0 || questions.every((_, i) => selections[i] !== undefined)
const allAnswered = freeText.trim().length > 0 || questions.every((_, i) => selectionToArray(selections[i]).length > 0)
const safeActiveTab = Math.min(activeTab, questions.length - 1)
const activeQuestion = questions[safeActiveTab]
@@ -168,7 +199,7 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) {
<div className="flex px-4 border-b border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-container-low)] overflow-x-auto">
{questions.map((q, i) => {
const isActive = safeActiveTab === i
const isAnswered = selections[i] !== undefined
const isAnswered = selectionToArray(selections[i]).length > 0
const tabLabel = q.header || `Q${i + 1}`
return (
<button
@@ -202,8 +233,15 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) {
{/* Option cards */}
{activeQuestion.options && activeQuestion.options.length > 0 && (
<div className="space-y-2 mb-3">
{activeQuestion.multiSelect && (
<p className="text-[11px] text-[var(--color-text-tertiary)] mb-1">
{t('question.multiSelectHint')}
</p>
)}
{activeQuestion.options.map((opt, optIndex) => {
const isSelected = selections[activeTab] === opt.label
const selectedLabels = selectionToArray(selections[safeActiveTab])
const isSelected = selectedLabels.includes(opt.label)
const isMulti = !!activeQuestion.multiSelect
return (
<button
key={optIndex}
@@ -216,8 +254,10 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) {
} ${submitted ? 'cursor-default' : ''}`}
>
<div className="flex items-start gap-3">
{/* Check indicator */}
<div className={`mt-0.5 flex-shrink-0 w-4 h-4 rounded-full border-2 flex items-center justify-center transition-colors ${
{/* Check indicator — square for multi-select, circle for single. */}
<div className={`mt-0.5 flex-shrink-0 w-4 h-4 border-2 flex items-center justify-center transition-colors ${
isMulti ? 'rounded-[3px]' : 'rounded-full'
} ${
isSelected
? 'border-[var(--color-secondary)] bg-[var(--color-secondary)]'
: 'border-[var(--color-outline)]'
@@ -184,7 +184,12 @@ export function PermissionDialog({ requestId, toolName, input, description }: Pr
<span className="truncate">{details.primary}</span>
</div>
) : null}
{preview}
{/* Cap preview height so big diffs / long bash commands don't push
the action buttons below the fold. User scrolls inside the
preview to see the rest; allow/deny stay reachable. */}
<div className="max-h-[360px] overflow-auto rounded-[var(--radius-md)]">
{preview}
</div>
</div>
) : details.primary ? (
<div className="mb-2">
@@ -114,9 +114,10 @@ export function SessionTaskBar() {
function TaskItem({ task }: { task: CLITask }) {
const config = statusConfig[task.status]
const markTaskCompletedLocally = useCLITaskStore((s) => s.markTaskCompletedLocally)
return (
<div className="flex items-start gap-2 py-1.5 px-1 rounded-md">
<div className="group flex items-start gap-2 py-1.5 px-1 rounded-md">
<span
className="material-symbols-outlined text-[16px] mt-px shrink-0"
style={{ color: config.color, fontVariationSettings: "'FILL' 1" }}
@@ -154,6 +155,20 @@ function TaskItem({ task }: { task: CLITask }) {
</span>
)}
</div>
{/* Manual override for when the agent forgets to close an in_progress
task. Only shown on hover for non-completed rows. Click marks it
completed locally; a later TodoWrite from the agent still wins. */}
{task.status !== 'completed' && (
<button
type="button"
onClick={() => markTaskCompletedLocally(task.id)}
title="标记完成 / Mark as done"
className="opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity shrink-0 mt-0.5 px-1 py-0.5 rounded text-[10px] text-[var(--color-text-tertiary)] hover:text-[var(--color-success)] hover:bg-[var(--color-surface-container-high)]"
>
<span className="material-symbols-outlined text-[14px] align-middle">check</span>
</button>
)}
</div>
)
}
@@ -135,22 +135,19 @@ export function HeicodeLoginPage() {
{/* docs/product-package/08 §"登录" + §"客户端不应该出现的内容":
Login screen only shows the Heicode provider. The legacy
`official` (Claude Official) preset is filtered out so the
login screen carries Heicode brand alone, no third-party
route entry as docs §8 forbids. */}
{hasFetched && providers.filter((p) => p.id !== 'official').length === 0 ? (
`official` (Claude Official) preset was removed from the
provider type entirely; no filter needed any more. */}
{hasFetched && providers.length === 0 ? (
<div className="max-w-sm rounded-[var(--radius-md)] border border-[var(--color-error)]/30 bg-[var(--color-error-container)] p-4 text-sm text-[var(--color-error)]">
{t('login.errors.noProviders')}
</div>
) : null}
{hasFetched && providers.filter((p) => p.id !== 'official').length > 0 ? (
{hasFetched && providers.length > 0 ? (
<div className="grid w-full max-w-sm grid-cols-1 gap-4">
{providers
.filter((provider) => provider.id !== 'official')
.map((provider) => (
<ProviderLoginCard key={provider.id} provider={provider} />
))}
{providers.map((provider) => (
<ProviderLoginCard key={provider.id} provider={provider} />
))}
</div>
) : null}
+3 -2
View File
@@ -432,7 +432,7 @@ export const en = {
'settings.about.author': 'Author',
'settings.about.socialMedia': 'Social Media',
'settings.about.updates': 'App Updates',
'settings.about.updatesDesc': 'Check GitHub Releases, download the installer, and relaunch after install.',
'settings.about.updatesDesc': 'Check the Heicode update source, download the installer, and relaunch after install.',
// Settings > Computer Use
'settings.tab.computerUse': 'Computer Use',
@@ -661,6 +661,7 @@ export const en = {
'question.typePlaceholder': 'Type your answer...',
'question.submit': 'Submit',
'question.answeredPrefix': 'Answered: ',
'question.multiSelectHint': 'Multiple choice — click to toggle',
// ─── Thinking Block ──────────────────────────────────────
'thinking.label': 'Thinking',
@@ -909,7 +910,7 @@ export const en = {
'update.currentVersionUnknown': 'Unknown',
'update.newVersion': 'New version v{version} available',
'update.downloading': 'Downloading...',
'update.idle': 'Check for updates to compare your installed version with the latest GitHub Release.',
'update.idle': 'Check for updates to compare your installed version with the latest available release.',
'update.now': 'Update now',
'update.later': 'Later',
'update.progress': 'Downloading update... {progress}%',
+3 -2
View File
@@ -434,7 +434,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.about.author': '作者',
'settings.about.socialMedia': '社交媒体',
'settings.about.updates': '应用更新',
'settings.about.updatesDesc': '检查 GitHub Releases,下载安装包,并在安装后自动重启。',
'settings.about.updatesDesc': '检查 Heicode 官方更新源,下载安装包,并在安装后自动重启。',
// Settings > Computer Use
'settings.tab.computerUse': 'Computer Use',
@@ -663,6 +663,7 @@ export const zh: Record<TranslationKey, string> = {
'question.typePlaceholder': '输入你的回答...',
'question.submit': '提交',
'question.answeredPrefix': '已回答: ',
'question.multiSelectHint': '可多选(点击切换)',
// ─── Thinking Block ──────────────────────────────────────
'thinking.label': '思考中',
@@ -911,7 +912,7 @@ export const zh: Record<TranslationKey, string> = {
'update.currentVersionUnknown': '未知版本',
'update.newVersion': '新版本 v{version} 可用',
'update.downloading': '下载中...',
'update.idle': '点击检查更新,对比当前安装版本和 GitHub Releases 的最新版本。',
'update.idle': '点击检查更新,对比当前安装版本和官方更新源的最新版本。',
'update.now': '立即更新',
'update.later': '稍后',
'update.progress': '正在下载更新... {progress}%',
-31
View File
@@ -1554,12 +1554,6 @@ function PluginSettings() {
// ─── About Settings ──────────────────────────────────────
const SOCIAL_LINKS = [
{ name: 'Bilibili', icon: '/icons/bilibili.svg', url: 'https://space.bilibili.com/434377496', label: '程序员阿江-Relakkes' },
{ name: 'Douyin', icon: '/icons/douyin.svg', url: 'https://www.douyin.com/user/MS4wLjABAAAATJPY7LAlaa5X-c8uNdWkvz0jUGgpw4eeXIwu_8BhvqE', label: '程序员阿江-Relakkes' },
{ name: 'Xiaohongshu', icon: '/icons/xiaohongshu.svg', url: 'https://www.xiaohongshu.com/user/profile/5f58bd990000000001003753', label: '程序员阿江-Relakkes' },
] as const
function AboutSettings() {
const t = useTranslation()
const [version, setVersion] = useState('')
@@ -1596,10 +1590,6 @@ function AboutSettings() {
void initialize()
}, [initialize])
const openUrl = (url: string) => {
import('@tauri-apps/plugin-shell').then((mod) => mod.open(url)).catch(() => window.open(url, '_blank'))
}
const checkedAtText =
checkedAt
? new Date(checkedAt).toLocaleString(undefined, {
@@ -1739,27 +1729,6 @@ function AboutSettings() {
</div>
</div>
{/* Divider */}
<div className="w-full border-t border-[var(--color-border)]/40 my-6" />
{/* Social Media */}
<div className="w-full mt-4">
<h3 className="text-xs font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider mb-3">{t('settings.about.socialMedia')}</h3>
<div className="flex flex-col gap-0.5">
{SOCIAL_LINKS.map((link) => (
<button
key={link.name}
onClick={() => openUrl(link.url)}
className="w-full flex items-center gap-3 px-4 py-2.5 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors cursor-pointer"
>
<img src={link.icon} alt={link.name} className="w-4 h-4 opacity-60" />
<span className="text-sm text-[var(--color-text-primary)]">{link.label}</span>
<span className="text-xs text-[var(--color-text-tertiary)] ml-auto">{link.name}</span>
</button>
))}
</div>
</div>
</div>
)
}
@@ -29,6 +29,10 @@ type CLITaskStore = {
refreshTasks: () => Promise<void>
/** Update tasks from TodoWrite V1 tool input (in-memory, no disk read needed) */
setTasksFromTodos: (todos: TodoItem[]) => void
/** Manually flip a stuck task to 'completed' (local override; a later
* TodoWrite would still re-sync from the agent's truth). For when the
* model forgets to close a task and the user wants to clear the panel. */
markTaskCompletedLocally: (taskId: string) => void
/** Mark that completed tasks were already dismissed (conversation continued) */
markCompletedAndDismissed: () => void
/** Clear a completed task list locally and remotely so the next cycle starts clean */
@@ -140,6 +144,20 @@ export const useCLITaskStore = create<CLITaskStore>((set, get) => ({
}))
},
markTaskCompletedLocally: (taskId) => {
set((state) => {
const tasks = state.tasks.map((task) =>
task.id === taskId && task.status !== 'completed'
? { ...task, status: 'completed' as TaskStatus, activeForm: undefined }
: task,
)
return {
tasks,
...resolveDismissState(tasks, state.dismissedCompletionKey),
}
})
},
markCompletedAndDismissed: () => {
const completionKey = buildCompletedTaskKey(get().tasks)
if (!completionKey) return
+5 -5
View File
@@ -1,11 +1,11 @@
{
"version": "0.1.0",
"notes": "0.1.0 首次发版 — wallet §4 实时数据 + StreamingIndicator 工具进度显示 + 更新通道闭环验证",
"pub_date": "2026-05-13T04:54:40.158Z",
"version": "0.1.1",
"notes": "0.1.1 — 审批弹窗按钮不再被大 diff 挤走、任务卡死可手动 ✓ 闭合、AskUserQuestion 支持可多选、文案修正",
"pub_date": "2026-05-13T06:06:08.282Z",
"platforms": {
"windows-x86_64": {
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSa3lBOFkxcW5LdDYwMjd0VHQyaThMY3F4MUI3TzdqY1ZVaFZHOEM0TWovK0ZqN25pZ1VmbWJieTBmaXg4UkM5MWpVTHM4UFNRamZsUS9jNncvQldQVDZqUGRwajA4WHdNPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzc4NjQ2MDQ3CWZpbGU6SGVpQ29kZV8wLjEuMF94NjQtc2V0dXAuZXhlCktUNVBUYWZCZVNFZFJXU2JqRGFtejVjU2FMbDRNSFlSWlYxVVFidE1TWmxTemtJRlRDekY3dEtEdFE0N0poenZrMjVNditHTTNXS0tkK002cG1QN0FBPT0K",
"url": "https://heicodeblob.blob.core.windows.net/msi/desktop/0.1.0/HeiCode_0.1.0_x64-setup.exe"
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSa3lBOFkxcW5LdHhtQUxDK2h0YWc3aHhBM3N5NFF3TEgxMFV1V285dzdZRkpIT1VTdTl4b2pXekplNHBRQmpHNktmbitocjc3dEdkZmUyQUNwbUVUaHN5V1BUS2NwTGdRPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzc4NjUyMzE0CWZpbGU6SGVpQ29kZV8wLjEuMV94NjQtc2V0dXAuZXhlCk9hMkF0azhCUnRSNjZIZ1VmN1BTZi9Kdmowdk93aEltcDI3bGlsRm9RcHJiMjAwQkRhTytQd0lyaVBFYXg1Vk0rQjE5MXZaSkcwM0FINThrY2d2cUNBPT0K",
"url": "https://heicodeblob.blob.core.windows.net/msi/desktop/0.1.1/HeiCode_0.1.1_x64-setup.exe"
}
}
}