Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions .claude/rules/adrs/ADR-025-user-workspace-alpha-dir.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,11 @@ related: [ADR-019, ADR-021, ADR-022, ADR-031, REQ-065, REQ-071]
- ✅ 复用面全部现成:lazy mkdir(主进程)、出厂 skill 通道(REQ-065)、自动化模板(ADR-022 standard 档)、回流守卫模式(ADR-021)——净新增 = 默认接线 + 一个 skill + 白名单受控扩展。
- ⚠️ 新增一个**可见写入面**:约定对会话 agent 是 skill 软约束(可能不遵守),硬保证只对自动化/回流产物成立——诚实分层,不装硬。
- ⚠️ `~/Alpha` 被用户改名/删除后 lazy 供给会重建空目录(数据不找回);属预期,文档明示。
- 🔭 执行载体 [[REQ-071]](T1 基础供给 / T2 契约配套);记忆能力本体后续单独立项。
- 🔭 执行载体 [[REQ-071]] (T1 基础供给 / T2 契约配套);记忆能力本体后续单独立项。

## 修订(2026-07-19,[[ADR-031]] —— §6 治理边界的两条结论被窄修订)

[[ADR-031]](混合用户记忆,`proposed`)窄修订本 ADR §6 的**两条**结论。两条都只在 Memory 面生效,`Journal/`、`Outputs/` 与 `~/Alpha` 其余部分**完全不受影响**。
[[ADR-031]] (混合用户记忆,`proposed`)窄修订本 ADR §6 的**两条**结论。两条都只在 Memory 面生效,`Journal/`、`Outputs/` 与 `~/Alpha` 其余部分**完全不受影响**。

**① 「不做 iCloud/同步/备份」→ Memory 面允许用户显式发起的选择性云发布。**

Expand All @@ -74,3 +74,29 @@ related: [ADR-019, ADR-021, ADR-022, ADR-031, REQ-065, REQ-071]
背景与决策第 1 条中的全局 `~/.alpha` 表述保留为 2026-07-09 的历史结论。自本修订起,全局安装物与
账本改位于 `<appData>/alpha-code-state/env/<environment>`;退休 home 根不读、不写、不迁移、
不 dual-read。`~/Alpha` 的用户可见工作目录定位、项目级 `<project>/.alpha` 与 userData 职责不变。

## 修订(2026-07-28,REQ-126 —— §3 第 1 条「既有用户有项目照旧」被推翻)

owner 2026-07-28 拍板:**新对话在用户未显式选择目录时一律落默认对话目录 `~/Alpha`**,不再沿用
「上次使用 / 第一个项目」优先。这直接推翻 §3 第 1 条后半句「既有用户有项目照旧(上次使用优先,
不被改写)」——该半句自本修订起失效,前半句(无项目/未选时默认显示并使用 `~/Alpha`、新会话零
选择直接开始)不变,§3 其余两条(目录选择器 `defaultPath`、无项目态自动化/云任务落点)与本 ADR
其它小节一字不动。

理由(2026-07-09 的判断为什么不再成立):当时把「有项目的既有用户」当成需要保护的连续性,实际
效果是**新对话的落点由一个用户从未选过的历史值决定** —— REQ-126 的触发正是 owner 报告新对话页
显示 `alpha-code` 而不是 `~/Alpha`(基线 §1.4:目录来自侧栏 `mostRecentConcreteDir()`)。默认落
`~/Alpha`、要别的目录就在 chip 里显式选,比「猜一个最近项目」既可预期又可撤销。

落点与残留(诚实分层,不装已全做完):

- 新对话 draft 入口按本条改判接线:目录选择器回到新对话页且选中即对当前 draft 生效,未显式选择
时 chip 与标题都显示 `~/Alpha`(REQ-126 CODE-D);建 draft 时的默认目录供给与 `{ok:false}` 的
loud 处置归 CODE-C。
- **首页 chip 同步改判**:优先级由「第一个项目 → 默认目录兜底」改为「用户显式所选 → 默认对话
目录 → 项目兜底」,与新对话页同一口径(REQ-126 CODE-D)。
- 连带处置:`renderer.home.workspace.provisional_to_real` 启动探针原先钉死
「default → project」这一个方向,改判后该方向不再可能发生。探针**保留但改成方向无关**(任何
已解析工作区又换身份都记一次,payload 带 `fromSource`/`toSource`),因为它度量的那段可感知
跳变依然存在、只是方向翻转;钉死旧方向等于留一个永不触发的死探针。性能探针服从产品事实,
不得反过来用作保留已知 AC 冲突的理由。
168 changes: 48 additions & 120 deletions packages/ui-mac/src/renderer/alpha-ui/AlphaHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
// 不再 Portal 覆盖 upstream Home(alpha 模式下 upstream Home 叶不挂载,单一 page root);
// data + send go through the SDK (useAlphaProjects)。

import { createEffect, createMemo, createResource, createSignal, For, Show, onCleanup } from "solid-js"
import { createEffect, createMemo, createSignal, Show } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { type AlphaProjectsApi } from "../sidebar/use-projects"
import { sessionHref, projectLabel } from "../sidebar/route"
import { sessionHref } from "../sidebar/route"
import { AlphaComposer } from "./alpha-composer"
import { createDefaultWorkspaceDir } from "./default-workspace"
import { AlphaWorkspaceChip, visibleWorkspaces } from "./workspace-chip"
import { pushToast } from "./Toast"
import { Banner } from "./Banner"
import { useConfigHealth } from "./use-config-health"
Expand All @@ -31,56 +33,61 @@ export function AlphaHome(props: { projects: AlphaProjectsApi }) {
const { store } = props.projects
const configHealth = useConfigHealth()

const visibleProjects = createMemo(() => store.projects.filter((p) => p.worktree !== "/"))
const visibleProjects = createMemo(() => visibleWorkspaces(store.projects))

const [chosenWs, setChosenWs] = createSignal<string | undefined>(undefined)
const [wsOpen, setWsOpen] = createSignal(false)

// REQ-071/ADR-025:无项目/未选时默认落 ~/Alpha(路径查询不建目录;lazy 供给在真正开会话时
// 由 use-projects 经 workspaceEnsureDefault 触发)。既有用户有项目照旧(仍第一个项目优先)。
const [defaultWs] = createResource(async () => {
try {
return await window.api.workspaceDefaultDir()
} catch {
return undefined
}
})
const activeWs = createMemo(() => chosenWs() ?? visibleProjects()[0]?.worktree ?? defaultWs())
// REQ-071/ADR-025(2026-07-28 修订):未显式选择 ⇒ **默认对话目录 `~/Alpha` 权威**。原先是
// 「第一个项目优先、默认目录兜底」,即新对话的落点由一个用户从未选过的历史值决定 —— owner
// 拍板推翻,首页与新对话页同一口径(路径查询不建目录;lazy 供给在真正开会话时由 use-projects
// 经 workspaceEnsureDefault 触发)。要别的目录就在 chip 里显式选。
const defaultWs = createDefaultWorkspaceDir()
// 默认目录**还在解析**时一个工作区都不给:此刻拿第一个项目顶上去,用户在默认目录返回之前
// 回车就把会话真开在了他没选过的项目里(AC5 的实测破绽)。给 undefined ⇒ composer 走既有
// 「需要工作区」路径(拦下 + 弹选择器 + 提示),不新增加载态设计。
const activeWs = createMemo(() =>
chosenWs() ?? (defaultWs.loading ? undefined : (defaultWs() ?? visibleProjects()[0]?.worktree)),
)
const activeWsSource = createMemo<"chosen" | "project" | "default" | "none">(() =>
chosenWs() ? "chosen" : visibleProjects()[0]?.worktree ? "project" : defaultWs() ? "default" : "none",
chosenWs()
? "chosen"
: defaultWs.loading
? "none"
: defaultWs()
? "default"
: visibleProjects()[0]?.worktree
? "project"
: "none",
)
// REQ-109/110 启动探针:首页先显示一个临时工作区、随后换成真正的那个 —— 这段可感知跳变仍然
// 存在,只是方向随上面的改判翻了个个儿(过去是 default→project,现在是 project→default,取决
// 于项目列表与默认目录哪个先到)。所以探针**保留但改成方向无关**:任何「已解析出的工作区又
// 换了身份」都记一次,并把 from/to 的来源一并带上,让分析侧自己分辨是哪一种。
// (钉死旧方向 = 留一个永不触发的死探针,那正是本 REQ 在清的「说谎的拓扑」。)
let previousWorkspace: { value: string | undefined; source: ReturnType<typeof activeWsSource> } | undefined
createEffect(() => {
const current = { value: activeWs(), source: activeWsSource() }
if (
previousWorkspace?.source === "default" &&
current.source === "project" &&
previousWorkspace &&
previousWorkspace.source !== "chosen" &&
current.source !== "chosen" &&
previousWorkspace.value &&
current.value &&
previousWorkspace.value !== current.value
)
markStartupTimeline("renderer.home.workspace.provisional_to_real", {
candidate: "A",
// 语义已变:旧口径的 candidate A = `default → 真实项目`(见 REQ-109 T1 证据文档),
// 改判后是任意方向。换掉标识,免得按候选聚合的既有口径把两种方向混成一桶。
candidate: "A-any-direction",
from: previousWorkspace.value,
to: current.value,
trigger: "projects-ready",
fromSource: previousWorkspace.source,
toSource: current.source,
trigger: "workspace-resolved",
})
previousWorkspace = current
})
const activeWsLabel = createMemo(() => {
const w = activeWs()
const p = visibleProjects().find((x) => x.worktree === w)
return p?.name ?? (w ? projectLabel(w) : t("alpha.home.chooseWorkspace"))
})

const onDoc = (e: MouseEvent) => {
const target = e.target as Element | null
if (target && target.closest(".a-pop-wrap")) return
setWsOpen(false)
}
document.addEventListener("click", onDoc)
onCleanup(() => document.removeEventListener("click", onDoc))
const stop = (e: Event) => e.stopPropagation()

return (
<div class="a-ui a-home a-home--page" data-alpha-home>
Expand Down Expand Up @@ -126,96 +133,17 @@ export function AlphaHome(props: { projects: AlphaProjectsApi }) {
}}
/>

{/* workspace chip */}
<div class="a-home-ws">
<div class="a-pop-wrap">
<button
class="a-ws-chip"
onClick={(e) => {
stop(e)
setWsOpen(!wsOpen())
}}
>
<FolderIcon /> {activeWsLabel()}
<Chevron />
</button>
<Show when={wsOpen()}>
<div class="a-pop a-pop-up" onClick={stop} style={{ "min-width": "240px" }}>
<div class="a-pop-label">{t("alpha.home.workspace")}</div>
{/* REQ-071:默认工作目录 ~/Alpha 常驻可选(未注册为项目时也在) */}
<Show when={defaultWs() && !visibleProjects().some((p) => p.worktree === defaultWs())}>
<button
class="a-pop-item"
classList={{ "is-on": activeWs() === defaultWs() }}
onClick={() => (setChosenWs(defaultWs()), setWsOpen(false))}
>
<span class="a-pico" style={{ background: "var(--a-accent)" }}>
A
</span>
{t("alpha.brand.short")}
<span class="a-pop-desc">{t("alpha.home.defaultWorkspace")}</span>
</button>
</Show>
<For each={visibleProjects()}>
{(p) => (
<button
class="a-pop-item"
classList={{ "is-on": activeWs() === p.worktree }}
onClick={() => (setChosenWs(p.worktree), setWsOpen(false))}
>
<span class="a-pico" style={{ background: p.color || "var(--a-accent)" }}>
{p.name.slice(0, 1).toUpperCase()}
</span>
{p.name}
</button>
)}
</For>
<div class="a-pop-sep" />
<button
class="a-pop-item"
onClick={() => {
setWsOpen(false)
// REQ-068:不再借上游 project.open —— 它只把目录加进上游 layout 的项目列表,
// 而本工作区列表读引擎 project.list(两套不通),观感=选完没反应。改为 alpha
// 自己选目录并**立即切换工作区**;项目在首条消息 startChat(directory) 时由
// 引擎正式注册(chip 标签对未注册目录有 projectLabel 兜底)。取消 = 静默。
void (async () => {
try {
const dir = await window.api.openDirectoryPicker({ title: t("alpha.home.openProject") })
const picked = Array.isArray(dir) ? dir[0] : dir
if (typeof picked === "string" && picked) setChosenWs(picked)
} catch {
pushToast({ kind: "error", title: t("alpha.home.openProjectFailed") })
}
})()
}}
>
<Plus /> {t("alpha.home.openProjectEllipsis")}
</button>
</div>
</Show>
</div>
</div>
{/* workspace chip —— 与新对话页同源的受控组件(REQ-126 CODE-D) */}
<AlphaWorkspaceChip
projects={visibleProjects()}
defaultWorkspace={defaultWs()}
value={activeWs()}
open={wsOpen()}
onOpenChange={setWsOpen}
onSelect={setChosenWs}
/>
</div>
</div>
</div>
)
}

/* ── inline icons ─────────────────────────────────────────────────────────── */
const ico = "0 0 24 24"
const Plus = () => (
<svg class="a-ic" viewBox={ico}>
<path d="M12 5v14M5 12h14" />
</svg>
)
const Chevron = () => (
<svg class="a-ic a-chev" viewBox={ico}>
<path d="M6 9l6 6 6-6" />
</svg>
)
const FolderIcon = () => (
<svg class="a-ic a-ic-sm" viewBox={ico}>
<path d="M3 7l2-3h5l2 3h7a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1z" />
</svg>
)
39 changes: 35 additions & 4 deletions packages/ui-mac/src/renderer/alpha-ui/alpha-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -559,9 +559,23 @@ export type AlphaComposerProps = {
onSubmitted?: (sessionID: string) => void
/** REQ-086:一次性预填文本(deep link `?prompt=`),仅初始化时注入,不覆盖用户后续输入。 */
initialText?: string
/** REQ-126:与 `initialText` 同期一次性注入的 mention / 附件(新对话页切目录重挂后的还原)。 */
initialMentions?: readonly MentionPart[]
initialAttachments?: readonly ComposerAttachment[]
/** REQ-125 C558:卸载时回报当前草稿(seam dock per-identity 暂存用);门翻转卸载 composer
* 时据此捕获正在输入的草稿,门翻回时经 `initialText` 注入,使卸载不等于不可恢复的丢失。 */
onDraftCapture?: (text: string) => void
/** REQ-126:卸载时回报**完整**草稿(文本 + mention + 附件)。与 `onDraftCapture` 同一时机、
* 同一份 sending 判据,只是内容更全 —— 新对话页切目录会重挂整叶,只留文本仍然是丢内容。 */
onDraftSnapshot?: (draft: {
text: string
mentions: readonly MentionPart[]
attachments: readonly ComposerAttachment[]
}) => void
/** REQ-126:附件正在被 FileReader 读取。读取未完成时 composer 里还没有这份附件,此刻重挂
* 必丢(旧实例的 cleanup 已跑完,读完的回调写不回任何地方)。宿主据此**拦住**会导致重挂的
* 操作(切目录)并明确提示,而不是让它静默消失。 */
onAttachmentReadPending?: (pending: boolean) => void
}

type ReadState<T> = { status: "ready"; data: T } | { status: "error" }
Expand Down Expand Up @@ -622,14 +636,16 @@ export function AlphaComposerRuntime(props: AlphaComposerRuntimeProps) {
// 仅当发送在途**且文本仍等于已提交快照**(未编辑,正在交付)才跳过——否则切走再翻回会「复活」
// 已发送文本、用户再发 = 重复发送。textarea 在途仍可编辑:改成不同内容即新草稿,照常捕获(不丢)。
// 失败保留走既有失败路径(sending 落回 false 后一律捕获,text 留在 composer 信号供原地重试)。
// 注:mentions / attachments 在下方声明;cleanup 只在 dispose 时执行,那时两者早已初始化。
onCleanup(() => {
if (sending() && text() === submittedText) return
props.onDraftCapture?.(text())
props.onDraftSnapshot?.({ text: text(), mentions: mentions(), attachments: attachments() })
})
const [modelChainState, setModelChainState] = createSignal<"loading" | "recovering" | "ready" | "error">(
"loading",
)
const [mentions, setMentions] = createSignal<MentionPart[]>([])
const [mentions, setMentions] = createSignal<MentionPart[]>([...(props.initialMentions ?? [])])
const [composing, setComposing] = createSignal(false)
let taRef: HTMLTextAreaElement | undefined
const isImeComposing = (e: KeyboardEvent) => e.isComposing || composing() || e.keyCode === 229
Expand All @@ -638,19 +654,24 @@ export function AlphaComposerRuntime(props: AlphaComposerRuntimeProps) {
/* ── 附件真通道(REQ-078 T2:图片/PDF → dataUrl FilePart;纯核 = composer-attachments-core)──
入口三通道:弹窗「添加附件」→ 隐藏 <input type=file>;textarea 粘贴;整框拖拽。
不合规(类型/超限)如实 toast 拒绝,绝不静默丢(C28 —— 旧「文件和文件夹」行正是静默吞)。 */
const [attachments, setAttachments] = createSignal<ComposerAttachment[]>([])
const [attachments, setAttachments] = createSignal<ComposerAttachment[]>([...(props.initialAttachments ?? [])])
const [dragOver, setDragOver] = createSignal(false)
// REQ-126:有附件正在读盘(FileReader 未 settle)。这段窗口里内容既不在 attachments() 里、
// 也无处可存 —— 宿主据此拦住会触发重挂的操作(见 onAttachmentReadPending)。
const [attachmentReads, setAttachmentReads] = createSignal(0)
createEffect(() => props.onAttachmentReadPending?.(attachmentReads() > 0))
let fileInputRef: HTMLInputElement | undefined
let attSeq = 0
const readAsDataUrl = (f: File) =>
new Promise<string>((resolve, reject) => {
const r = new FileReader()
r.onload = () => (typeof r.result === "string" ? resolve(r.result) : reject(new Error("read")))
r.onerror = () => reject(r.error ?? new Error("read"))
// abort 也必须 settle:漏了它,读取计数永不归零,工作区切换从此被永久拦住。
r.onabort = () => reject(new Error("attachment read aborted"))
r.readAsDataURL(f)
})
const addFiles = async (list: ArrayLike<File> | null | undefined) => {
if (!list || list.length === 0) return
const readFilesInto = async (list: ArrayLike<File>) => {
const rejected: Array<{ name: string; reason: string }> = []
const accepted: ComposerAttachment[] = []
for (const f of Array.from(list)) {
Expand All @@ -677,6 +698,16 @@ export function AlphaComposerRuntime(props: AlphaComposerRuntimeProps) {
detail: bad.map((item) => `${item.name}: ${attachmentReason(item.reason)}`).join("; "),
})
}
const addFiles = async (list: ArrayLike<File> | null | undefined) => {
if (!list || list.length === 0) return
// 计数(不是布尔):并发的多次粘贴/拖拽各自 settle,最后一个读完才算不再挂起。
setAttachmentReads((n) => n + 1)
try {
await readFilesInto(list)
} finally {
setAttachmentReads((n) => n - 1)
}
}
const removeAttachment = (id: string) => setAttachments((xs) => xs.filter((a) => a.id !== id))
const hasDragFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes("Files")

Expand Down
Loading
Loading