Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
aae6420
feat(assistant): coordinate work through a durable inbox bot
xxhZs Oct 15, 2026
712d531
fix(assistant): reconcile durable wakes and canonical outbox retries
xxhZs Oct 15, 2026
b4ce59d
fix(assistant): clarify bot duties and separate discovery configuration
xxhZs Oct 16, 2026
f1a5fcb
refactor(assistant): focus bot instructions on duties and mechanics
xxhZs Oct 16, 2026
d6ff911
refactor(assistant): clarify bot duties and delegation
xxhZs Oct 16, 2026
619d936
fix(memory): share and parallelize source refreshes in index listings
xxhZs Oct 16, 2026
01546d7
feat(assistant): add expandable message summaries and typing indicator
xxhZs Oct 16, 2026
5bb4956
fix(assistant): recover interrupted wakes and resolve review checks
xxhZs Oct 16, 2026
bac660d
fix(browser): allow selected sessions to operate hidden browser panels
xxhZs Oct 16, 2026
788bbb1
fix(ui): render summary markdown and unify expanded body colors
xxhZs Oct 16, 2026
e781f3f
fix(assistant): use Astryx summary button and update SendUser fixtures
xxhZs Oct 16, 2026
af03f0d
Merge remote-tracking branch 'origin/main' into feat/assistant-inbox-bot
xxhZs Oct 17, 2026
9cba5c2
fix(assistant): preserve chat empty states and fence browser and mess…
xxhZs Oct 17, 2026
db11126
fix(desktop): remove unused personal assistant re-exports
xxhZs Oct 17, 2026
f471bce
fix(assistant): address remaining provenance, safety and renderer nits
xxhZs Oct 18, 2026
c59ff5a
fix(bot): retain production connector provenance through turn admission
xxhZs Oct 18, 2026
df549e5
fix(assistant): retry failed initiative binding resolution
xxhZs Oct 18, 2026
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
47 changes: 47 additions & 0 deletions apps/desktop/src/main/__tests__/session-local.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1286,6 +1286,53 @@ test('a Message dispatched in a released epoch is settled from the Host, never r
assert.equal(store.get('authority', 'message-1')?.result?.disposition, 'turn_started');
});

test('an assistant Inbox receipt survives a released epoch without replaying or inventing a Turn', async (t) => {
const { store, beforeClose } = await database(t);
const submissions: TurnMessageSubmitInput[] = [];
const queried: string[][] = [];
const target: DesktopSessionLocalTarget = {
partition: 'authority',
profileId: 'profile',
scope: { hostId: 'root', targetEpoch: 'target-1' },
client: {
...client('epoch-2'),
async queryMessageExecutions(input) {
queried.push([...input.messageIds]);
return {
resolutions: [
{ messageId: 'message-1', state: 'inbox' },
],
};
},
},
submit: async (input) => {
submissions.push(input);
return accepted;
},
};
const service = new DesktopSessionLocalService(store, {
targets: () => [target],
changed() {},
onError: (error) => assert.fail(String(error)),
});
beforeClose.push(() => service.close());
// Dispatch once in the previous epoch, then lose the answer the way a Host
// restart does: the local copy is left `unknown` with a dead dispatch epoch.
const dispatched = store.enqueue('authority', intent());
store.update({
...dispatched,
state: 'unknown',
intent: { ...dispatched.intent, originHostEpoch: 'epoch-1' },
});
service.wake();
await waitFor(() => store.get('authority', 'message-1')?.state === 'accepted');
assert.deepEqual(queried, [['message-1']]);
// The old epoch's submit is never replayed: that answer can only be
// `outcome_unknown`, which would freeze the copy forever.
assert.deepEqual(submissions, []);
assert.equal(store.get('authority', 'message-1')?.result?.disposition, 'inbox');
});

test('a stale-epoch Message the Host never admitted is retired so later sends proceed', async (t) => {
const { store, beforeClose } = await database(t);
const submissions: string[] = [];
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/session-local-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,8 @@ export class DesktopSessionLocalService {
},
error: undefined,
});
} else if (resolution?.state === 'inbox') {
this.store.update({ ...current, state: 'accepted', result: { disposition: 'inbox', skillInvocation: EMPTY_SKILL_INVOCATION }, error: undefined });
} else if (resolution?.state === 'pending') {
// The Host durably holds a queued admission for it. Delivery succeeded;
// the Host queue owns the ordering, so this local copy yields.
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/preload/bridge-contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1281,7 +1281,7 @@ export interface MakaBridge {
): Promise<
| {
ok: true;
disposition: 'turn_started' | 'steering' | 'followup' | 'locally_saved';
disposition: 'turn_started' | 'steering' | 'followup' | 'locally_saved' | 'inbox';
turnId?: string;
attachments: import('@maka/core/events').AttachmentRef[];
inlineReferences: import('@maka/core/events').InlineReference[];
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1305,6 +1305,7 @@ function AppShellContent({
onOpenWorkHub={openWorkHub} onOpenSession={(sessionId) => { closeSettings(); openSession(sessionId); }} />
<WorkHubDock workbar={selectors} workbarTogglePosition={workbarTogglePosition} visible={workHubActive && sessionsSelected && !shellObscured} />
<ChatSurfaceLayout
className={activeSessionForView?.labels?.includes('personal-assistant') ? 'personal-assistant-chat' : undefined}
data-session-history-surface="true"
// ChatView positions this transcript: switching conversations,
// following the tail and the moves the reader asks for are one
Expand Down Expand Up @@ -1364,6 +1365,9 @@ function AppShellContent({
contextPickEnabled={canStageComposerContext}
executorComposer={{ selection: executor, taskSubmissionHardBlocked, connectionCount: connections.length, onSetup: () => openSettingsSection('external-agents'), onNewTask: openNewTaskSurface }}
activeSession={activeSessionForView}
compactControls={activeSessionForView?.labels?.includes('personal-assistant')}
placeholder={activeSessionForView?.labels?.includes('personal-assistant')
? (uiLocale === 'zh-CN' ? '聊点什么…' : 'Send a message…') : undefined}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The inline locale ternary sends zh-TW to English and grows app-shell.tsx tokens, which the architecture ratchet rejects. Use a catalog entry owned by the feature.

{...{ executorTarget, onExecutorTargetChange }}
usageModel={activeModel}
usageRoute={activeSessionForModelControls}
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/renderer/chat-message-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
useUiLocale,
type LiveTurnProjection,
} from '@maka/ui';
import { PersonalAssistantAvatar, useAssistantAvatar } from './features/personal-assistant/index.js';
import { OnboardingHero } from './onboarding-hero';
import type { SessionHealthNoticeView, SessionUiReads } from './features/conversation/index.js';
import type { WorkspaceReadinessRecovery } from './workspace-readiness-recovery';
Expand Down Expand Up @@ -124,6 +125,9 @@ export function ChatMessageSurface({
onSkip,
...chatViewRest
}: ChatMessageSurfaceProps) {
const isAssistant = chatViewRest.activeSession?.labels?.includes('personal-assistant') === true;
const avatar = useAssistantAvatar(isAssistant, activeSessionId);
const assistantAvatar = useMemo(() => isAssistant ? <PersonalAssistantAvatar avatar={avatar} /> : undefined, [isAssistant, avatar]);
const locale = useUiLocale();
const copy = getShellCopy(locale).app;
// Configuration notices share the Settings label; identity recovery supplies
Expand Down Expand Up @@ -204,6 +208,7 @@ export function ChatMessageSurface({
{(goalProjection) => (
<StagedQuoteChatView
{...chatViewRest}
assistantAvatar={assistantAvatar}
liveTurns={seededLiveTurns}
// Every branch above reseeds `sessionId` to `activeSessionId`, and a
// render-phase setState re-runs this body before anything commits, so
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ export function createChatActions<Owner extends ComposerSurfaceOwner>(deps: {
id: messageId,
text: input.displayText ?? skillFeedback.skillInvocationDisplayText(input.command.text, result.skillInvocation),
attachments: [...result.attachments],
transientPlacement: result.disposition !== 'turn_started' && placement === 'next_turn' ? 'follow_up' : 'transcript',
transientPlacement: result.disposition !== 'turn_started' && result.disposition !== 'inbox' && placement === 'next_turn' ? 'follow_up' : 'transcript',
...(result.turnId ? { hostTurnId: result.turnId } : {}),
...copiedArray('directoryReferences', directoryReferences),
...copiedArray('quotes', input.quotes ?? []),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export interface ConversationMessageCommand {
export type ConversationMessageSubmission =
| {
readonly ok: true;
readonly disposition: 'turn_started' | 'steering' | 'followup' | 'locally_saved';
readonly disposition: 'turn_started' | 'steering' | 'followup' | 'locally_saved' | 'inbox';
readonly turnId?: string;
readonly attachments: AttachmentRef[];
readonly inlineReferences: InlineReference[];
Expand Down
76 changes: 76 additions & 0 deletions apps/desktop/src/renderer/features/personal-assistant/avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { useEffect, useState } from 'react';
import { DEFAULT_ASSISTANT_AVATAR, assistantAvatarSchema, type AssistantAvatar } from '@maka/runtime-host/protocol';
import { usePersonalAssistantServices } from './services.js';
import { originalHost } from './navigation.js';

type AvatarView = AssistantAvatar & { imageUrl?: string };

export function useAssistantAvatar(enabled: boolean, sessionId?: string) {
const services = usePersonalAssistantServices();
const [state, setState] = useState<{ scope?: string; avatar: AvatarView }>({ avatar: DEFAULT_ASSISTANT_AVATAR });
useEffect(() => {
if (!enabled) return;
let live = true;
let cachedImage: { id: string; url: string } | undefined;
let timer: ReturnType<typeof setTimeout>;
const refresh = async () => {
try {
const host = sessionId
? originalHost(sessionId, (await services.hostSnapshot()).entries).host
: await services.defaultHost();
const result = await services.call('assistant.avatar', {}, host);
const avatar: AvatarView = assistantAvatarSchema.parse(result.avatar);
if (avatar.image) {
if (cachedImage?.id !== avatar.image.id) {
let data = '';
while (live && data.length < avatar.image.length) {
const chunk = await services.call('assistant.avatar-image', { id: avatar.image.id, offset: data.length }, host);
if (chunk.id !== avatar.image.id || chunk.offset !== data.length || typeof chunk.data !== 'string'
|| !chunk.data.length || data.length + chunk.data.length > avatar.image.length) throw Error('Invalid avatar image');
data += chunk.data;
}
if (!live) return;
cachedImage = { id: avatar.image.id, url: `data:${avatar.image.mimeType};base64,${data}` };
}
avatar.imageUrl = cachedImage.url;
} else cachedImage = undefined;
if (live) setState((old) => old.scope === sessionId && old.avatar.emoji === avatar.emoji && old.avatar.background === avatar.background && old.avatar.imageUrl === avatar.imageUrl
? old : { scope: sessionId, avatar });
} catch {
// A disconnected Host must not display another Host's identity.
if (live) setState({ scope: sessionId, avatar: DEFAULT_ASSISTANT_AVATAR });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: On Host failure this sets a fresh object every 3 s. That changes the assistantAvatar element and re-renders every memoized TurnView. Dedupe like the success path does.

} finally {
if (live) timer = setTimeout(refresh, 3000);
}
};
void refresh();
return () => { live = false; clearTimeout(timer); };
}, [enabled, sessionId, services]);
return enabled && state.scope === sessionId ? state.avatar : DEFAULT_ASSISTANT_AVATAR;
}

export function PersonalAssistantAvatar({ avatar }: { avatar: AvatarView }) {
return <span className="personal-assistant-avatar" role="img" aria-label="个人助手头像"
style={{ backgroundColor: avatar.background }}>
{avatar.imageUrl ? <img src={avatar.imageUrl} alt="" draggable={false} /> : avatar.emoji}
</span>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ import {
useShowAssistantBackgroundSessions,
} from '../../application/contracts/session-catalog/assistant-session-visibility.js';
import { SideNavItem } from '@astryxdesign/core/SideNav';
import { Sparkles } from '@maka/ui/icons';
import { PersonalAssistantAvatar, useAssistantAvatar } from './avatar.js';
export { PersonalAssistantAvatar, useAssistantAvatar } from './avatar.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 (CI): Knip (apps/desktop) fails on this head because PersonalAssistantAvatar and useAssistantAvatar are no longer imported through the barrel. chat-presentation.tsx imports ./avatar.js directly, and this file uses them through line 27. Deleting this re-export, together with the PersonalAssistantTyping re-export at line 467, makes npx knip --workspace apps/desktop pass locally, and the strict-base architecture check still passes.

import { Switch } from '@maka/ui';
import { usePersonalAssistantServices, type AssistantHost as DesktopRuntimeHostRef } from './services.js';
export { createPersonalAssistantServices, PersonalAssistantServicesProvider, type PersonalAssistantPort } from './services.js';
Expand Down Expand Up @@ -86,6 +87,7 @@ export function PersonalAssistantEntry({
const enablement = useAssistantEnablement();
const [bound, setBound] = useState<string>();
const [error, setError] = useState('');
const avatar = useAssistantAvatar(enablement.enabled, bound);
useEffect(() => {
if (!enablement.enabled) {
setBound(undefined);
Expand Down Expand Up @@ -117,7 +119,7 @@ export function PersonalAssistantEntry({
return (
<SideNavItem
label="个人助手"
icon={Sparkles}
icon={<PersonalAssistantAvatar avatar={avatar} />}
size="md"
isSelected={!!bound && bound === activeSessionId}
aria-label={error ? '个人助手:' + error : '个人助手'}
Expand Down Expand Up @@ -297,6 +299,7 @@ function PersonalAssistantStatusSettings({
return (
<div className="personal-assistant-settings">
{error && <p role="alert">{error}</p>}
{data.bot?.error && <p role="alert">助手消息处理暂时停止:{data.bot.error}。发送新消息可重试,Inbox 中的消息会保留。</p>}
{!state ? (
<p>请先从侧栏打开个人助手,创建专属会话。</p>
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,108 @@
.personal-assistant-settings button:disabled { opacity: .5; cursor: default; }
.personal-assistant-settings button:hover:not(:disabled) { background: var(--muted); }
.pa-controls { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0; }

/* A conversational surface on the existing transcript and composer. */
.personal-assistant-chat {
--maka-reading-measure: 720px;
--maka-turn-gap: 10px;
--chat-user-bg: color-mix(in srgb, var(--accent) 20%, var(--background));
}
.personal-assistant-chat .maka-prompt-rail-host { display: none; }
.personal-assistant-chat .maka-turn { gap: 8px; }
.personal-assistant-chat .maka-chat-message-bubble {
border-radius: 8px;
position: relative;
overflow: visible;
padding: 8px 12px;
line-height: 1.5;
}
.personal-assistant-chat .maka-chat-message-bubble-assistant {
width: fit-content;
max-width: 84%;
background: var(--muted);
}
.personal-assistant-chat .maka-assistant-answer-content { max-width: 100%; }
.personal-assistant-chat .maka-turn[data-turn-id^="assistant-output:"] .maka-turn-statusbar[data-turn-status="completed"] { display: none; }
.personal-assistant-chat .maka-turn-footer { margin-top: 0; }
/* Hover actions float in the row instead of reserving an empty line per message.
Touch keeps the visible controls in flow; keyboard focus also reveals them. */
@media (hover: hover) {
.personal-assistant-chat .maka-chat-message { position: relative; }
.personal-assistant-chat .maka-user-message:not(:has(.maka-message-delivery, .maka-message-status-time)) > div > div > div:has(> .maka-message-meta),
.personal-assistant-chat .maka-assistant-answer .maka-turn-footer {
position: absolute;
bottom: 0;
width: auto;
margin: 0;
padding: 2px 6px;
border-radius: 10px;
background: var(--background);
z-index: 1;
}
.personal-assistant-chat .maka-user-message:not(:has(.maka-message-delivery, .maka-message-status-time)) > div > div > div:has(> .maka-message-meta) { left: 0; }
.personal-assistant-chat .maka-assistant-answer .maka-turn-footer { right: 0; }
.personal-assistant-chat .maka-message-meta { margin-top: 0; }
}
.personal-assistant-chat .maka-composer-astryx { --_chat-composer-radius: 24px; }
.personal-assistant-chat .maka-composer-configuration { position: relative; }
.personal-assistant-chat .maka-composer-configuration > summary {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 50%;
color: var(--muted-foreground);
cursor: pointer;
list-style: none;
}
.personal-assistant-chat .maka-composer-configuration > summary::-webkit-details-marker { display: none; }
.personal-assistant-chat .maka-composer-configuration > summary:hover,
.personal-assistant-chat .maka-composer-configuration[open] > summary { background: var(--muted); }
.personal-assistant-chat .maka-composer-configuration > summary:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.personal-assistant-chat .maka-composer-configuration-panel {
position: absolute;
bottom: calc(100% + 12px);
left: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
border: 1px solid var(--border);
border-radius: 16px;
background: var(--background);
box-shadow: 0 8px 28px color-mix(in srgb, var(--foreground) 10%, transparent);
z-index: var(--z-popover, 50);
max-width: min(520px, calc(100vw - 100px));
flex-wrap: wrap;
}

.personal-assistant-avatar {
display: inline-grid;
place-items: center;
width: 30px;
height: 30px;
flex: 0 0 30px;
border-radius: 7px;
overflow: hidden;
font-size: 19px;
line-height: 1;
user-select: none;
}
.personal-assistant-chat .maka-assistant-answer { align-items: flex-start; }
.personal-assistant-chat .maka-assistant-answer .personal-assistant-avatar { margin-top: 3px; }

/* More specific than the shared transcript paint containment. */
.personal-assistant-chat .maka-chat-message-bubble[data-maka-transcript-boundary] { contain: layout style; }
.personal-assistant-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.personal-assistant-chat .maka-chat-message-bubble::before {
content: "";
position: absolute;
top: 13px;
width: 6px;
height: 10px;
background: inherit;
pointer-events: none;
}
.personal-assistant-chat .maka-chat-message-bubble-assistant::before { left: -6px; clip-path: polygon(100% 0, 100% 100%, 0 50%); }
.personal-assistant-chat .maka-chat-message-bubble-user::before { right: -6px; clip-path: polygon(0 0, 100% 50%, 0 100%); }
2 changes: 1 addition & 1 deletion apps/desktop/src/shared/session-local-contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export interface DesktopLocalMessage {
readonly quotes?: readonly QuoteRef[];
readonly inlineReferences: readonly InlineReference[];
readonly turnId?: string;
readonly admission?: 'turn_started' | 'steering' | 'followup';
readonly admission?: 'turn_started' | 'steering' | 'followup' | 'inbox';
readonly checking?: boolean;
readonly retryScheduled?: boolean;
readonly waitingForConnection?: boolean;
Expand Down
Loading
Loading