From 158556a8fef21e5af6a6d381f2ae807373f4fdcc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?=
Date: Tue, 11 Aug 2026 19:40:22 +0800
Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20=E4=B8=AD=E7=BB=A7=E6=94=BE?=
=?UTF-8?q?=E5=BC=80=20kind=3Ddesktop=EF=BC=9A=E6=A1=8C=E9=9D=A2=E7=AB=AF?=
=?UTF-8?q?=E7=99=BB=E8=AE=B0=E5=8F=AF=E5=AF=BB=E5=9D=80=E7=9B=AE=E6=A0=87?=
=?UTF-8?q?=E5=B9=B6=E8=A2=AB=E5=90=8C=E8=B4=A6=E5=8F=B7=E8=A7=A3=E6=9E=90?=
=?UTF-8?q?=EF=BC=8C=E8=B7=A8=E8=B4=A6=E5=8F=B7/=E9=9D=9E=E6=B4=BB?=
=?UTF-8?q?=E8=B7=83/=E5=B7=B2=E8=A7=A3=E9=99=A4=E6=8E=88=E6=9D=83?=
=?UTF-8?q?=E4=BB=8D=E6=8B=92=EF=BC=88=E4=BB=BB=E5=8A=A1=201=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
internal/controller/relay_ctr/relay_test.go | 55 +++++++++++++--
internal/service/relay_svc/relay.go | 10 ++-
internal/service/relay_svc/relay_test.go | 75 ++++++++++++++-------
3 files changed, 107 insertions(+), 33 deletions(-)
diff --git a/internal/controller/relay_ctr/relay_test.go b/internal/controller/relay_ctr/relay_test.go
index e2af0dff..62514749 100644
--- a/internal/controller/relay_ctr/relay_test.go
+++ b/internal/controller/relay_ctr/relay_test.go
@@ -227,6 +227,53 @@ func TestRelayEndpointsRequireDeviceJWTAndDaemonRenewsOnFrames(t *testing.T) {
}
}
+func TestDesktopRelayTargetCanBeAddressedThroughEndpoints(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ testutils.Redis()
+ mini := miniredis.RunT(t)
+ signer, err := jwt.NewSigner(testkeys.PrivatePEM, testkeys.PublicPEM, "agentre-server", "agentre")
+ require.NoError(t, err)
+
+ controller := gomock.NewController(t)
+ devices := mock_device_repo.NewMockDeviceRepo(controller)
+ desktop := &device_entity.Device{
+ ID: 10, UserID: 7, Kind: device_entity.KindDesktop, Fingerprint: "fp-desktop", Status: 1,
+ }
+ devices.EXPECT().Find(gomock.Any(), desktop.ID).Return(desktop, nil)
+ devices.EXPECT().FindByFingerprint(gomock.Any(), desktop.UserID, desktop.Fingerprint).Return(desktop, nil)
+
+ config := relay_svc.Config{InstanceID: "server-a", OnlineTTL: time.Second}
+ redisClient := newRelayRedisClient(t, mini)
+ server := newRelayServer(t, signer, relay_svc.New(
+ config, devices, redisClient, relay_svc.NewRedisForwarder(config, redisClient),
+ ))
+ targetToken, _, err := signer.Sign(jwt.Claims{
+ UID: desktop.UserID, DID: desktop.ID, Kind: device_entity.KindDesktop,
+ }, time.Hour)
+ require.NoError(t, err)
+ clientToken, _, err := signer.Sign(jwt.Claims{
+ UID: desktop.UserID, DID: 4, Kind: device_entity.KindAgentred,
+ }, time.Hour)
+ require.NoError(t, err)
+
+ targetConn, _, err := websocket.DefaultDialer.Dial(
+ wsURL(server.URL, "/v1/relay/daemon"),
+ http.Header{"Authorization": {"Bearer " + targetToken}},
+ )
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, targetConn.Close()) })
+
+ clientConn, response, err := websocket.DefaultDialer.Dial(
+ wsURL(server.URL, "/v1/relay/client?daemon_fingerprint="+desktop.Fingerprint),
+ http.Header{"Authorization": {"Bearer " + clientToken}},
+ )
+ if response != nil {
+ t.Cleanup(func() { require.NoError(t, response.Body.Close()) })
+ }
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, clientConn.Close()) })
+}
+
func TestRelayLifecycleRejectsOversizedMessagesAndDetaches(t *testing.T) {
gin.SetMode(gin.TestMode)
testutils.Redis()
@@ -321,15 +368,15 @@ func TestRelayClientForwardingErrorStillClosesClientConnection(t *testing.T) {
require.Error(t, err)
}
-// PrepareDaemon 的准入判据(本账号名下、活跃的 agentred)被拒时必须走 403,而且
-// 必须**在 websocket upgrade 之前**答复:一个 desktop 端的 device JWT 拿不到升级后
-// 的连接,也就没机会占住这个账号+指纹的中继路由。403 这一支此前没有任何测试。
+// PrepareDaemon 的准入判据(本账号名下、活跃且可寻址的设备)被拒时必须走 403,
+// 而且必须**在 websocket upgrade 之前**答复:不支持的设备种类拿不到升级后的连接,
+// 也就没机会占住这个账号+指纹的中继路由。
func TestRelayDaemonForbiddenAnswers403BeforeUpgrading(t *testing.T) {
gin.SetMode(gin.TestMode)
testutils.Redis()
signer, err := jwt.NewSigner(testkeys.PrivatePEM, testkeys.PublicPEM, "agentre-server", "agentre")
require.NoError(t, err)
- token, _, err := signer.Sign(jwt.Claims{UID: 7, DID: 4, Kind: device_entity.KindDesktop}, time.Hour)
+ token, _, err := signer.Sign(jwt.Claims{UID: 7, DID: 4, Kind: device_entity.KindWeb}, time.Hour)
require.NoError(t, err)
stub := &relayStub{
diff --git a/internal/service/relay_svc/relay.go b/internal/service/relay_svc/relay.go
index 021646ad..c225667a 100644
--- a/internal/service/relay_svc/relay.go
+++ b/internal/service/relay_svc/relay.go
@@ -155,19 +155,23 @@ func New(config Config, devices device_repo.DeviceRepo, redisClient *goredis.Cli
}
func (s *relaySvc) PrepareDaemon(ctx context.Context, accountID, deviceID int64, kind string) (Route, error) {
- if kind != device_entity.KindAgentred {
+ if !isAddressableKind(kind) {
return Route{}, ErrDaemonForbidden
}
device, err := s.devices.Find(ctx, deviceID)
if err != nil {
return Route{}, err
}
- if device == nil || device.UserID != accountID || device.Kind != device_entity.KindAgentred || !device.IsActive() {
+ if device == nil || device.UserID != accountID || !isAddressableKind(device.Kind) || !device.IsActive() {
return Route{}, ErrDaemonForbidden
}
return Route{AccountID: accountID, Fingerprint: device.Fingerprint, InstanceID: s.config.InstanceID}, nil
}
+func isAddressableKind(kind string) bool {
+ return kind == device_entity.KindAgentred || kind == device_entity.KindDesktop
+}
+
func (s *relaySvc) RegisterDaemon(ctx context.Context, route Route) error {
if err := s.redis.Set(ctx, routeKey(route.AccountID, route.Fingerprint), route.InstanceID, s.config.OnlineTTL).Err(); err != nil {
return fmt.Errorf("register relay daemon: %w", err)
@@ -198,7 +202,7 @@ func (s *relaySvc) ConnectClient(ctx context.Context, accountID int64, fingerpri
if err != nil {
return Route{}, err
}
- if device == nil || device.Kind != device_entity.KindAgentred || !device.IsActive() {
+ if device == nil || !isAddressableKind(device.Kind) || !device.IsActive() {
return Route{}, ErrDaemonNotFound
}
diff --git a/internal/service/relay_svc/relay_test.go b/internal/service/relay_svc/relay_test.go
index 59a42c91..c400cf4e 100644
--- a/internal/service/relay_svc/relay_test.go
+++ b/internal/service/relay_svc/relay_test.go
@@ -39,6 +39,13 @@ func activeDaemon() *device_entity.Device {
}
}
+func activeDesktop() *device_entity.Device {
+ return &device_entity.Device{
+ ID: 10, UserID: 7, Kind: device_entity.KindDesktop,
+ Fingerprint: "fp-desktop", Status: 1,
+ }
+}
+
func TestIsDaemonOnline(t *testing.T) {
ctx := context.Background()
svc, mini, _ := newRelayForTest(t, fakeForwarder{})
@@ -155,26 +162,34 @@ func TestAttachClientDetachSignalsChannelCloseToDaemon(t *testing.T) {
}
}
-// PrepareDaemon 是 /v1/relay/daemon 唯一的准入判据:只有本账号名下、活跃的
-// agentred 设备才能把自己登记成中转目标。没有这几条,一个 desktop 端的 device JWT
-// 就能冒充计算节点占住这个账号+指纹的中继路由,或者拿别人账号下的 deviceID 登记。
-// 这些守卫此前一条测试都没有,整段删掉全绿。
-func TestPrepareDaemonRejectsAnythingButThisAccountsActiveAgentred(t *testing.T) {
+func TestDesktopCanRegisterAndBeResolvedWithinAccount(t *testing.T) {
ctx := context.Background()
+ svc, _, devices := newRelayForTest(t, fakeForwarder{})
+ desktop := activeDesktop()
+ devices.EXPECT().Find(gomock.Any(), desktop.ID).Return(desktop, nil)
+ devices.EXPECT().FindByFingerprint(gomock.Any(), desktop.UserID, desktop.Fingerprint).Return(desktop, nil)
- t.Run("非 agentred 设备种类", func(t *testing.T) {
- svc, _, _ := newRelayForTest(t, fakeForwarder{})
- // kind 在读库之前就被拒,连 Find 都不该发生(devices mock 没有 EXPECT)。
- _, err := svc.PrepareDaemon(ctx, 7, 9, device_entity.KindDesktop)
- require.ErrorIs(t, err, ErrDaemonForbidden)
- })
+ route, err := svc.PrepareDaemon(ctx, desktop.UserID, desktop.ID, device_entity.KindDesktop)
+ require.NoError(t, err)
+ require.Equal(t, Route{
+ AccountID: desktop.UserID, Fingerprint: desktop.Fingerprint, InstanceID: "server-a",
+ }, route)
+ require.NoError(t, svc.RegisterDaemon(ctx, route))
+
+ resolved, err := svc.ConnectClient(ctx, desktop.UserID, desktop.Fingerprint)
+ require.NoError(t, err)
+ require.Equal(t, route, resolved)
+}
+
+// PrepareDaemon 是 /v1/relay/daemon 唯一的准入判据:只有本账号名下、活跃且可寻址的
+// agentred 或 desktop 设备才能把自己登记成中转目标。kind、账号归属和撤销状态必须在
+// websocket upgrade 前复核,避免无权设备占住账号+指纹的中继路由。
+func TestPrepareDaemonAcceptsOnlyThisAccountsActiveAddressableDevices(t *testing.T) {
+ ctx := context.Background()
- t.Run("kind=web 无法登记为可寻址目标(R3 守卫断言)", func(t *testing.T) {
+ t.Run("kind=web 无法登记为可寻址目标", func(t *testing.T) {
svc, _, _ := newRelayForTest(t, fakeForwarder{})
- // 浏览器与桌面端一样是中继上的纯出站调用方:持有有效设备 JWT 只能连
- // /v1/relay/client,绝不能把自己登记成可被寻址的目标(也不能被派活)。
- // 这条由 PrepareDaemon 的既有 kind 判定执行——同样在读库之前就被拒,
- // 连 Find 都不该发生(devices mock 没有 EXPECT)。
+ // kind 在读库之前就被拒,连 Find 都不该发生(devices mock 没有 EXPECT)。
_, err := svc.PrepareDaemon(ctx, 7, 9, device_entity.KindWeb)
require.ErrorIs(t, err, ErrDaemonForbidden)
})
@@ -188,17 +203,14 @@ func TestPrepareDaemonRejectsAnythingButThisAccountsActiveAgentred(t *testing.T)
require.ErrorIs(t, err, ErrDaemonForbidden)
})
- t.Run("设备已被撤销", func(t *testing.T) {
+ t.Run("撤销一台 desktop 不影响其它设备", func(t *testing.T) {
svc, _, devices := newRelayForTest(t, fakeForwarder{})
- revoked := activeDaemon()
+ revoked := activeDesktop()
revoked.Status = 2
- devices.EXPECT().Find(gomock.Any(), int64(9)).Return(revoked, nil)
- _, err := svc.PrepareDaemon(ctx, 7, 9, device_entity.KindAgentred)
+ devices.EXPECT().Find(gomock.Any(), revoked.ID).Return(revoked, nil)
+ _, err := svc.PrepareDaemon(ctx, revoked.UserID, revoked.ID, device_entity.KindDesktop)
require.ErrorIs(t, err, ErrDaemonForbidden)
- })
- t.Run("本账号活跃的 agentred 才放行", func(t *testing.T) {
- svc, _, devices := newRelayForTest(t, fakeForwarder{})
devices.EXPECT().Find(gomock.Any(), int64(9)).Return(activeDaemon(), nil)
route, err := svc.PrepareDaemon(ctx, 7, 9, device_entity.KindAgentred)
require.NoError(t, err)
@@ -834,11 +846,22 @@ func TestDefaultIsNeverNilWithoutRegistration(t *testing.T) {
func TestRelayClientFailuresAreDistinguishable(t *testing.T) {
ctx := context.Background()
- t.Run("daemon is not registered to this account", func(t *testing.T) {
+ t.Run("target belongs to another account", func(t *testing.T) {
svc, _, devices := newRelayForTest(t, fakeForwarder{})
- devices.EXPECT().FindByFingerprint(gomock.Any(), int64(7), "fp-unknown").Return(nil, nil)
+ // FindByFingerprint 按账号限定查询;另一个账号里的同指纹对账号 7 不可见。
+ devices.EXPECT().FindByFingerprint(gomock.Any(), int64(7), "fp-other-account").Return(nil, nil)
+
+ _, err := svc.ConnectClient(ctx, 7, "fp-other-account")
+ require.ErrorIs(t, err, ErrDaemonNotFound)
+ })
+
+ t.Run("revoked desktop is not addressable", func(t *testing.T) {
+ svc, _, devices := newRelayForTest(t, fakeForwarder{})
+ revoked := activeDesktop()
+ revoked.Status = 2
+ devices.EXPECT().FindByFingerprint(gomock.Any(), revoked.UserID, revoked.Fingerprint).Return(revoked, nil)
- _, err := svc.ConnectClient(ctx, 7, "fp-unknown")
+ _, err := svc.ConnectClient(ctx, revoked.UserID, revoked.Fingerprint)
require.ErrorIs(t, err, ErrDaemonNotFound)
})
From 6229cfe81af592130ae44ca72a36de4df156699a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?=
Date: Tue, 11 Aug 2026 23:40:27 +0800
Subject: [PATCH 2/5] =?UTF-8?q?=E2=9C=A8=20Web=20=E6=8E=A7=E5=88=B6?=
=?UTF-8?q?=E5=8F=B0=E6=94=AF=E6=8C=81=E6=A1=8C=E9=9D=A2=E7=AB=AF=E5=AF=B9?=
=?UTF-8?q?=E8=AF=9D=E4=B8=8B=E9=92=BB=E3=80=81=E4=BC=9A=E8=AF=9D=E5=90=88?=
=?UTF-8?q?=E5=B9=B6=E4=B8=8E=E5=85=AD=E7=B1=BB=E7=8A=B6=E6=80=81=E8=A1=A8?=
=?UTF-8?q?=E8=BE=BE=EF=BC=88=E4=BB=BB=E5=8A=A1=208=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/__tests__/chat.test.tsx | 117 +++++++++++++++
frontend/src/__tests__/device-expand.test.tsx | 73 +++++++++
.../src/__tests__/device-sessions.test.tsx | 79 +++++++++-
.../src/__tests__/session-detail.test.tsx | 33 +++++
frontend/src/__tests__/session-merge.test.ts | 95 ++++++++++++
.../src/__tests__/session-status.test.tsx | 53 +++++--
frontend/src/components/session/ChatList.tsx | 21 ++-
.../components/session/SessionDetailView.tsx | 33 ++++-
.../session/SessionStatusBanner.tsx | 13 +-
frontend/src/i18n/locales/en.json | 8 +
frontend/src/i18n/locales/zh-CN.json | 8 +
frontend/src/lib/sessionMerge.ts | 50 +++++++
frontend/src/lib/sessionView.ts | 44 ++++--
frontend/src/pages/Chat.tsx | 140 +++++++++++++-----
frontend/src/pages/DeviceSessions.tsx | 40 +++--
frontend/src/pages/Devices.tsx | 41 +++--
16 files changed, 745 insertions(+), 103 deletions(-)
create mode 100644 frontend/src/__tests__/session-merge.test.ts
create mode 100644 frontend/src/lib/sessionMerge.ts
diff --git a/frontend/src/__tests__/chat.test.tsx b/frontend/src/__tests__/chat.test.tsx
index 13c1bf75..44bf935d 100644
--- a/frontend/src/__tests__/chat.test.tsx
+++ b/frontend/src/__tests__/chat.test.tsx
@@ -497,6 +497,123 @@ describe("对话页:行上的时间是最后活动时间", () => {
});
});
+describe("对话页:R20 重复会话合并", () => {
+ const desktop = {
+ id: 3,
+ name: "工作 MacBook",
+ kind: "desktop",
+ fingerprint: "fp-desktop",
+ last_seen_at: 1754000000000,
+ status: 1,
+ online: true,
+ };
+
+ function duplicateFollows() {
+ return [
+ {
+ device_fingerprint: "fp-agentred",
+ session_id: "42",
+ followed_at: 1754000000000,
+ invalid: false,
+ },
+ {
+ device_fingerprint: "fp-desktop",
+ session_id: "42",
+ followed_at: 1754000000000,
+ invalid: false,
+ },
+ ];
+ }
+
+ it("同键的桌面端与 agentred 摘要只呈现桌面端完整副本", async () => {
+ const compute = {
+ ...agentred,
+ fingerprint: "fp-agentred",
+ };
+ mockedApi.mockImplementation(async (path) => {
+ if (path === "/v1/follows") return { items: duplicateFollows() };
+ if (path === "/v1/devices") return { devices: [compute, desktop] };
+ if (path === "/v1/workspace/agents") return { agents };
+ throw new Error("unexpected: " + path);
+ });
+ mockUseRelay.mockImplementation((fingerprint) => ({
+ ...connectedRelay(),
+ client: {
+ ...fakeClient,
+ request: vi.fn(async () => ({
+ sessions: [
+ {
+ ...summary,
+ peerFingerprint: "fp-desktop",
+ title:
+ fingerprint === "fp-desktop"
+ ? "Complete desktop title"
+ : "Partial agentred title",
+ },
+ ],
+ supportsSessionMetadata: true,
+ })),
+ } as never,
+ }));
+
+ renderChat();
+
+ // 会话在「最近」区与所属 Agent 分组各渲染一次(T6),用 *AllBy 断言合并结果:
+ // 只剩桌面端完整副本,没有 agentred 退化副本,也不带历史不完整说明。
+ expect(
+ (await screen.findAllByText("Complete desktop title")).length,
+ ).toBeGreaterThan(0);
+ expect(screen.queryByText("Partial agentred title")).toBeNull();
+ expect(screen.queryByText(/History is incomplete/)).toBeNull();
+ });
+
+ it("桌面端副本不在场时退到 agentred,并显示历史不完整说明", async () => {
+ const compute = {
+ ...agentred,
+ fingerprint: "fp-agentred",
+ };
+ mockedApi.mockImplementation(async (path) => {
+ if (path === "/v1/follows") {
+ return { items: [duplicateFollows()[0]] };
+ }
+ if (path === "/v1/devices") {
+ return { devices: [compute, { ...desktop, online: false }] };
+ }
+ if (path === "/v1/workspace/agents") return { agents };
+ throw new Error("unexpected: " + path);
+ });
+ mockUseRelay.mockReturnValue({
+ ...connectedRelay(),
+ client: {
+ ...fakeClient,
+ request: vi.fn(async () => ({
+ sessions: [
+ {
+ ...summary,
+ peerFingerprint: "fp-desktop",
+ title: "Partial agentred title",
+ },
+ ],
+ supportsSessionMetadata: true,
+ })),
+ } as never,
+ });
+
+ renderChat();
+
+ // 会话在「最近」区与所属 Agent 分组各渲染一次(T6),用 *AllBy 断言合并结果:
+ // 桌面端副本不在场时退到 agentred 副本并带历史不完整说明。
+ expect(
+ (await screen.findAllByText("Partial agentred title")).length,
+ ).toBeGreaterThan(0);
+ expect(
+ screen.getAllByText(
+ "History is incomplete — showing only the part retained by agentred.",
+ ).length,
+ ).toBeGreaterThan(0);
+ });
+});
+
// R13:这一页的每一行都靠 runtime.session.list 解析(标题 / 状态 / 等待输入 /
// 最后活动时间)。机器掉线再回来时必须**重新**解析一次:断连期间那条对话可能跑完
// 了、可能停下来等审批,而页面上还挂着断线前那一刻的状态——用户对着一个早就过时
diff --git a/frontend/src/__tests__/device-expand.test.tsx b/frontend/src/__tests__/device-expand.test.tsx
index f8194c53..267bc617 100644
--- a/frontend/src/__tests__/device-expand.test.tsx
+++ b/frontend/src/__tests__/device-expand.test.tsx
@@ -151,6 +151,79 @@ describe("device row expand", () => {
expect(within(card).getByText(/View this machine/i)).toBeTruthy();
});
+ it("expanding an active desktop row shows conversation counts and an enter action", async () => {
+ mockedApi.mockImplementation(async (path) => {
+ if (path === "/v1/devices") {
+ return {
+ devices: [
+ {
+ ...listResponse.devices[1],
+ online: true,
+ is_this_device: false,
+ },
+ ],
+ };
+ }
+ if (path === "/v1/workspace/device-detail?device_id=2") {
+ return {
+ device_id: 2,
+ kind: "desktop",
+ projects: [],
+ };
+ }
+ throw new Error("unexpected call: " + path);
+ });
+
+ renderDevices();
+ const card = (await screen.findByText("laptop")).closest(
+ '[data-slot="card"]',
+ ) as HTMLElement;
+ fireEvent.click(
+ within(card).getByRole("button", { name: /show details/i }),
+ );
+
+ expect(await within(card).findByText("Conversations")).toBeTruthy();
+ expect(await within(card).findByText("3 conversations")).toBeTruthy();
+ expect(within(card).getByText("1 waiting for you")).toBeTruthy();
+ const link = within(card).getByRole("link", {
+ name: "View this desktop's conversations",
+ });
+ expect(link.getAttribute("href")).toBe("/devices/2/sessions");
+ });
+
+ it("an inactive desktop says Agentre is not running and cannot be entered", async () => {
+ mockedApi.mockImplementation(async (path) => {
+ if (path === "/v1/devices") return listResponse;
+ if (path === "/v1/workspace/device-detail?device_id=2") {
+ return {
+ device_id: 2,
+ kind: "desktop",
+ projects: [],
+ };
+ }
+ throw new Error("unexpected call: " + path);
+ });
+
+ renderDevices();
+ const card = (await screen.findByText("laptop")).closest(
+ '[data-slot="card"]',
+ ) as HTMLElement;
+ expect(within(card).getByText(/Agentre is not running/)).toBeTruthy();
+ expect(within(card).queryByText(/^Offline$/)).toBeNull();
+
+ fireEvent.click(
+ within(card).getByRole("button", { name: /show details/i }),
+ );
+ expect(
+ await within(card).findByText(
+ /Agentre is not running on this computer\. Open Agentre to view its conversations\./,
+ ),
+ ).toBeTruthy();
+ expect(within(card).queryByRole("link", { name: /conversations/i })).toBe(
+ null,
+ );
+ });
+
// 帧 47:浏览器行不接单,也**不可展开** —— 展开它只会去问一台没有项目、没有
// Agent 的「设备」,把 agentred 的那套详情套在浏览器上是错的。
it("a kind=web row has no expand control", async () => {
diff --git a/frontend/src/__tests__/device-sessions.test.tsx b/frontend/src/__tests__/device-sessions.test.tsx
index faaa380f..c31e4cc4 100644
--- a/frontend/src/__tests__/device-sessions.test.tsx
+++ b/frontend/src/__tests__/device-sessions.test.tsx
@@ -116,8 +116,85 @@ describe("设备会话列表页", () => {
expect(fakeClient.request).toHaveBeenCalledWith("runtime.session.list");
});
+ it("在线桌面端复用同一套会话列表并消费完整 SessionSummary", async () => {
+ const desktop = {
+ ...deviceRow,
+ id: 1,
+ name: "工作 MacBook",
+ kind: "desktop",
+ fingerprint: "fp-desktop",
+ };
+ mockedApi.mockImplementation(async (path) => {
+ if (path === "/v1/devices") return { devices: [desktop] };
+ if (path === "/v1/workspace/agents") {
+ return { agents: [{ sync_id: "ag-1", name: "后端 Agent" }] };
+ }
+ if (path === "/v1/follows") return { items: [] };
+ throw new Error("unexpected: " + path);
+ });
+ mockUseRelay.mockReturnValue({
+ client: {
+ ...fakeClient,
+ request: vi.fn(async () => ({
+ sessions: [
+ {
+ ...sessions[0],
+ peerFingerprint: "fp-desktop",
+ title: "桌面完整标题",
+ },
+ ],
+ supportsSessionMetadata: true,
+ })),
+ } as never,
+ relayState: "connected",
+ webDevice: { fingerprint: "fp-web", accessToken: "t", deviceId: 9 },
+ webDeviceError: null,
+ });
+
+ renderPage();
+
+ expect(await screen.findByText("桌面完整标题")).toBeTruthy();
+ // 桌面端列表等待输入时用状态点 + aria-label 表达(#10 起文字徽标只在移动端)。
+ expect(
+ screen.getByTestId("session-dot-42").getAttribute("aria-label"),
+ ).toBe("Waiting for your input");
+ expect(mockUseRelay).toHaveBeenCalledWith("fp-desktop");
+ expect(screen.queryByText("Unnamed")).toBeNull();
+ });
+
+ it("未运行的桌面端直达列表页时说明打开 Agentre,不借用机器离线措辞", async () => {
+ mockedApi.mockImplementation(async (path) => {
+ if (path === "/v1/devices") {
+ return {
+ devices: [
+ {
+ ...deviceRow,
+ kind: "desktop",
+ fingerprint: "fp-desktop",
+ online: false,
+ },
+ ],
+ };
+ }
+ if (path === "/v1/workspace/agents") return { agents: [] };
+ throw new Error("unexpected: " + path);
+ });
+ mockUseRelay.mockReturnValue({
+ client: null,
+ relayState: "disconnected",
+ webDevice: null,
+ webDeviceError: null,
+ });
+
+ renderPage();
+
+ expect(await screen.findByText(/Open Agentre to continue/)).toBeTruthy();
+ expect(screen.queryByText(/This machine is offline/)).toBeNull();
+ expect(mockUseRelay).not.toHaveBeenCalledWith("fp-desktop");
+ });
+
// 界面(决策 11 / 帧 45a):「行尾一个「换机器」入口**就地切换**」——不是把人送回
- // 设备页再从头下钻一次。就地列出账号下的其余 agentred,选中即换到那台机器的会话列表。
+ // 设备页再从头下钻一次。就地列出账号下的其余目标,选中即换到那台机器的会话列表。
it("面包屑行尾「换机器」就地列出其余 agentred 并切过去", async () => {
const other = {
id: 2,
diff --git a/frontend/src/__tests__/session-detail.test.tsx b/frontend/src/__tests__/session-detail.test.tsx
index 3cf0ddf0..8d5e40f1 100644
--- a/frontend/src/__tests__/session-detail.test.tsx
+++ b/frontend/src/__tests__/session-detail.test.tsx
@@ -489,6 +489,39 @@ describe("会话详情页:老 agentred 与发送失败", () => {
).toBe(true);
});
+ it("桌面端在场但钉住的 agentred 不可用:历史仍可读、新写入停用并给专门说明", async () => {
+ mockedApi.mockImplementation(async (path) => {
+ if (path === "/v1/devices") {
+ return { devices: [{ ...deviceRow, kind: "desktop" }] };
+ }
+ throw new Error("unexpected: " + path);
+ });
+ fakeClient.request.mockImplementation(async (method) => {
+ if (method === "runtime.session.list") {
+ return { sessions: [summary], supportsSessionMetadata: true };
+ }
+ if (method === "runtime.session.pendingWaiters") {
+ return { toolPermissions: [], askUserQuestions: [] };
+ }
+ if (method === "runtime.run") throw new Error("pinned agentred offline");
+ throw new Error("unexpected: " + method);
+ });
+
+ renderPage();
+ await screen.findByText(/重构登录页/);
+
+ fireEvent.change(screen.getByRole("textbox"), {
+ target: { value: "继续" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Send" }));
+
+ expect(
+ await screen.findByText(/Conversation history is still available/),
+ ).toBeTruthy();
+ expect(screen.getByRole("textbox").hasAttribute("disabled")).toBe(true);
+ expect(screen.queryByText(/draft is kept/)).toBeNull();
+ });
+
it("发送失败:就地报错,不静默吞掉", async () => {
mockedApi.mockImplementation(async (path) => {
if (path === "/v1/devices") return { devices: [deviceRow] };
diff --git a/frontend/src/__tests__/session-merge.test.ts b/frontend/src/__tests__/session-merge.test.ts
new file mode 100644
index 00000000..26f27450
--- /dev/null
+++ b/frontend/src/__tests__/session-merge.test.ts
@@ -0,0 +1,95 @@
+import { describe, expect, it } from "vitest";
+
+import { mergeSessionCopies } from "@/lib/sessionMerge";
+
+const desktopSummary = {
+ sessionId: 42,
+ peerFingerprint: "fp-desktop",
+ title: "Complete desktop title",
+ agentSyncId: "ag-1",
+ lifecycleState: "idle",
+ latestSeq: 9,
+};
+
+const agentredSummary = {
+ ...desktopSummary,
+ title: "Partial agentred title",
+ latestSeq: 4,
+};
+
+describe("duplicate session merge", () => {
+ it("merges by peer fingerprint plus session id and prefers the desktop copy", () => {
+ const merged = mergeSessionCopies(
+ [
+ {
+ sourceFingerprint: "fp-agentred",
+ sourceKind: "agentred",
+ summary: agentredSummary,
+ },
+ {
+ sourceFingerprint: "fp-desktop",
+ sourceKind: "desktop",
+ summary: desktopSummary,
+ },
+ ],
+ new Set(["fp-desktop"]),
+ );
+
+ expect(merged).toHaveLength(1);
+ expect(merged[0].sourceKind).toBe("desktop");
+ expect(merged[0].summary.title).toBe("Complete desktop title");
+ expect(merged[0].historyIncomplete).toBe(false);
+
+ const reversed = mergeSessionCopies(
+ [
+ {
+ sourceFingerprint: "fp-desktop",
+ sourceKind: "desktop",
+ summary: desktopSummary,
+ },
+ {
+ sourceFingerprint: "fp-agentred",
+ sourceKind: "agentred",
+ summary: agentredSummary,
+ },
+ ],
+ new Set(["fp-desktop"]),
+ );
+ expect(reversed).toHaveLength(1);
+ expect(reversed[0].sourceKind).toBe("desktop");
+ });
+
+ it("does not merge equal session ids from different peer fingerprints", () => {
+ const merged = mergeSessionCopies([
+ {
+ sourceFingerprint: "fp-agentred-a",
+ sourceKind: "agentred",
+ summary: agentredSummary,
+ },
+ {
+ sourceFingerprint: "fp-agentred-b",
+ sourceKind: "agentred",
+ summary: { ...agentredSummary, peerFingerprint: "fp-other" },
+ },
+ ]);
+
+ expect(merged).toHaveLength(2);
+ });
+
+ it("falls back to the agentred copy and marks its retained history incomplete", () => {
+ const merged = mergeSessionCopies(
+ [
+ {
+ sourceFingerprint: "fp-agentred",
+ sourceKind: "agentred",
+ summary: agentredSummary,
+ },
+ ],
+ new Set(["fp-desktop"]),
+ );
+
+ expect(merged).toHaveLength(1);
+ expect(merged[0].sourceKind).toBe("agentred");
+ expect(merged[0].historyIncomplete).toBe(true);
+ });
+});
diff --git a/frontend/src/__tests__/session-status.test.tsx b/frontend/src/__tests__/session-status.test.tsx
index a40b2f60..8a7ae168 100644
--- a/frontend/src/__tests__/session-status.test.tsx
+++ b/frontend/src/__tests__/session-status.test.tsx
@@ -1,11 +1,11 @@
/**
- * R11 测试接缝:四类不可达与失效各自可区分、可访问性的文字标注。
+ * R11 测试接缝:六类失败/失效各自可区分、可访问性的文字标注。
*
* 断言三件事:
- * 1. 七个视图状态渲染出**互不相同**的文案(不折叠成同一个错误)。
- * 2. 状态不只靠颜色:运行 / 等待输入 / 离线 / 解除授权都有文字(getByText 能取到)。
- * 3. 实时通知类(reconnecting / connecting)走 role="status",失败类
- * (lost / machineOffline / revoked / loggedOut)走 role="alert"。
+ * 1. 所有视图状态渲染出**互不相同**的文案(不折叠成同一个错误)。
+ * 2. 状态不只靠颜色:六类失败都有可见文字(getByText 能取到)。
+ * 3. 实时通知类(reconnecting / connecting)走 role="status",失败类走
+ * role="alert"。
*/
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
@@ -37,11 +37,13 @@ const ALL_STATUSES: SessionViewStatus[] = [
"reconnecting",
"lost",
"machineOffline",
+ "desktopAppNotRunning",
+ "pinnedAgentredUnavailable",
"revoked",
"loggedOut",
];
-describe("会话状态:四类不可达与失效各自可区分", () => {
+describe("会话状态:六类失败与失效各自可区分", () => {
it("每种状态渲染出互不相同的可见文案", () => {
const texts = new Map();
for (const status of ALL_STATUSES) {
@@ -61,16 +63,25 @@ describe("会话状态:四类不可达与失效各自可区分", () => {
seen.add(own);
texts.set(status, own);
}
- // machineOffline / revoked / loggedOut 三者文案互不相同(四类不折叠)。
- expect(texts.get("machineOffline")).not.toBe(texts.get("revoked"));
- expect(texts.get("machineOffline")).not.toBe(texts.get("loggedOut"));
- expect(texts.get("revoked")).not.toBe(texts.get("loggedOut"));
+ const failures: SessionViewStatus[] = [
+ "machineOffline",
+ "lost",
+ "revoked",
+ "loggedOut",
+ "desktopAppNotRunning",
+ "pinnedAgentredUnavailable",
+ ];
+ expect(new Set(failures.map((status) => texts.get(status))).size).toBe(6);
});
it("状态不只靠颜色:关键状态都有可见文字(而非仅图标/色块)", () => {
renderStatus("machineOffline");
+ renderStatus("desktopAppNotRunning");
+ renderStatus("pinnedAgentredUnavailable");
renderStatus("revoked");
- expect(screen.getAllByRole("alert").length).toBe(2);
+ renderStatus("loggedOut");
+ renderStatus("lost");
+ expect(screen.getAllByRole("alert").length).toBe(6);
// 文字存在且非空。
const alerts = screen.getAllByRole("alert");
for (const a of alerts) {
@@ -86,17 +97,21 @@ describe("会话状态:四类不可达与失效各自可区分", () => {
expect(screen.getAllByRole("alert").length).toBe(2);
});
- it("deriveSessionViewStatus 把四类信号各自映射到不同状态", () => {
+ it("deriveSessionViewStatus 把六类失败信号各自映射到不同状态", () => {
const base: {
relayState: RelayState;
meValid: boolean;
webDeviceRevoked: boolean;
machineOnline: boolean | null;
+ targetKind: "agentred" | "desktop";
+ pinnedAgentredUnavailable: boolean;
} = {
relayState: "disconnected",
meValid: true,
webDeviceRevoked: false,
machineOnline: true,
+ targetKind: "agentred",
+ pinnedAgentredUnavailable: false,
};
expect(deriveSessionViewStatus({ ...base, meValid: false })).toBe(
"loggedOut",
@@ -107,6 +122,20 @@ describe("会话状态:四类不可达与失效各自可区分", () => {
expect(deriveSessionViewStatus({ ...base, machineOnline: false })).toBe(
"machineOffline",
);
+ expect(
+ deriveSessionViewStatus({
+ ...base,
+ targetKind: "desktop",
+ machineOnline: false,
+ }),
+ ).toBe("desktopAppNotRunning");
+ expect(
+ deriveSessionViewStatus({
+ ...base,
+ relayState: "connected",
+ pinnedAgentredUnavailable: true,
+ }),
+ ).toBe("pinnedAgentredUnavailable");
expect(deriveSessionViewStatus({ ...base, relayState: "connected" })).toBe(
"connected",
);
diff --git a/frontend/src/components/session/ChatList.tsx b/frontend/src/components/session/ChatList.tsx
index e15ed93c..2f8d81e2 100644
--- a/frontend/src/components/session/ChatList.tsx
+++ b/frontend/src/components/session/ChatList.tsx
@@ -58,6 +58,8 @@ export interface ChatSessionRow {
deviceName: string;
followedAt: number;
summary: SessionSummary;
+ /** 桌面副本不在场而退到 agentred 副本时,只读得到执行端保留的部分历史。 */
+ historyIncomplete?: boolean;
/** 移动形态下钉在行上的 Agent 名称与头像色(R13,见 regroupByStatus)。 */
agentName?: string;
agentColor?: string;
@@ -76,6 +78,7 @@ export interface ChatOfflineRow {
sessionId: number;
deviceId: number;
deviceName: string;
+ deviceKind: string;
lastSeenAt: number;
}
@@ -234,6 +237,11 @@ function SessionRowBody({
{row2}
)}
+ {row.historyIncomplete && (
+
+ {t("session.list.historyIncomplete")}
+
+ )}
{/* 移动端保文字徽标(不只靠颜色);桌面由状态点 + aria 承担。 */}
{isMobile && (
@@ -409,10 +417,15 @@ function OfflineRow({
}
>
- {t("chat.offlineMachineWithTime", {
- machine: row.deviceName,
- time: formatTime(row.lastSeenAt),
- })}
+ {t(
+ row.deviceKind === "desktop"
+ ? "chat.desktopAppNotRunningWithTime"
+ : "chat.offlineMachineWithTime",
+ {
+ machine: row.deviceName,
+ time: formatTime(row.lastSeenAt),
+ },
+ )}
{/* 决策 16:移动的关注入口在详情页顶栏,不在列表行。 */}
diff --git a/frontend/src/components/session/SessionDetailView.tsx b/frontend/src/components/session/SessionDetailView.tsx
index ba78f1d4..defd12cd 100644
--- a/frontend/src/components/session/SessionDetailView.tsx
+++ b/frontend/src/components/session/SessionDetailView.tsx
@@ -108,6 +108,9 @@ export default function SessionDetailView({
const [decisionError, setDecisionError] = useState(false);
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState(false);
+ // 桌面端仍在场时写失败:表示该会话钉住的 agentred 当前不可用(历史可读、新写入停用)。
+ const [pinnedAgentredUnavailable, setPinnedAgentredUnavailable] =
+ useState(false);
// 未升级的 agentred 的 session.list 应答里没有 supportsSessionMetadata(兼容性)。
const [needsUpgrade, setNeedsUpgrade] = useState(false);
const [draft, setDraft] = useState("");
@@ -132,6 +135,7 @@ export default function SessionDetailView({
setHandledRequestId(null);
setDecisionError(false);
setSendError(false);
+ setPinnedAgentredUnavailable(false);
setNeedsUpgrade(false);
setDraft("");
setFollowed(false);
@@ -187,7 +191,7 @@ export default function SessionDetailView({
});
const { client, relayState, webDevice, webDeviceError } = useRelayMachine(
- device?.fingerprint ?? null,
+ device?.online ? device.fingerprint : null,
{
onEvent: (f) => {
if (f.sessionId === sid) setEvents((prev) => [...prev, f]);
@@ -262,8 +266,10 @@ export default function SessionDetailView({
if (alive) {
setSummary(s ?? null);
// 老 agentred 落库不了 provider_session_id:从这里发消息续不上上下文。
- // 如实说明它需要升级,并停用发送——不静默发出去(兼容性)。
- setNeedsUpgrade(!list.supportsSessionMetadata);
+ // 如实说明它需要升级,并停用发送——不静默发出去(兼容性)。桌面端无此问题。
+ setNeedsUpgrade(
+ device?.kind !== "desktop" && !list.supportsSessionMetadata,
+ );
}
await client.attach(sid, origin);
await client.catchUp(sid, origin);
@@ -281,7 +287,8 @@ export default function SessionDetailView({
// ready 之后不再重复跑(重连时的补齐由 RelayClient.reconnect 对 watched 会话负责);
// ready / did / sid 也必须是依赖:切换会话时上面的渲染期重置把 ready 置 false,
// 这里要重新 attach 到新会话;只按 [client, relayState] 的话重置后不会重跑。
- }, [client, relayState, ready, did, sid]);
+ // device?.kind 也要进来:桌面端不存在老 agentred 的升级判定,取数完成后须重算。
+ }, [client, relayState, ready, did, sid, device?.kind]);
// 首次进入 reconnecting(= 连接失败)时探测原因(R11),只探一次。
// 与文件里其它异步 effect 一样带 alive 守卫:reconnecting 期间切换目标设备或
@@ -374,6 +381,8 @@ export default function SessionDetailView({
meValid,
webDeviceRevoked: revokedNow,
machineOnline,
+ targetKind: device?.kind,
+ pinnedAgentredUnavailable,
});
const items = useMemo(() => reduceEvents(events), [events]);
@@ -400,9 +409,16 @@ export default function SessionDetailView({
backend: { type: summary.backendType },
});
setDraft("");
+ setPinnedAgentredUnavailable(false);
} catch {
- // 保留草稿,并就地说明这一条没发出去——静默吞掉会让用户以为已经发了。
- setSendError(true);
+ // 桌面端仍在场时,写失败表示这条会话钉住的 agentred 当前不可用:历史继续
+ // 保持可读,但停用新写入并给出专门说明。agentred 目标保留既有发送失败措辞。
+ if (device?.kind === "desktop") {
+ setPinnedAgentredUnavailable(true);
+ } else {
+ // 保留草稿,并就地说明这一条没发出去——静默吞掉会让用户以为已经发了。
+ setSendError(true);
+ }
} finally {
setSending(false);
}
@@ -484,7 +500,10 @@ export default function SessionDetailView({
}
const showTranscript =
- ready && (status === "connected" || relayState === "reconnecting");
+ ready &&
+ (status === "connected" ||
+ status === "pinnedAgentredUnavailable" ||
+ relayState === "reconnecting");
// 详情头部的机器 meta:「设备 · 在线/离线」拼成同一文本节点(jsx-only 守卫要求
// 文案不出现裸字符串;分隔符在 JS 里拼,不进 JSX)。
diff --git a/frontend/src/components/session/SessionStatusBanner.tsx b/frontend/src/components/session/SessionStatusBanner.tsx
index 234a1e2a..f74cebb8 100644
--- a/frontend/src/components/session/SessionStatusBanner.tsx
+++ b/frontend/src/components/session/SessionStatusBanner.tsx
@@ -4,7 +4,7 @@ import { Alert } from "@/components/ui/alert";
import type { SessionViewStatus } from "@/lib/sessionView";
/**
- * R11 状态横幅:四类不可达与失效各有独立文案,不折叠成同一个错误。
+ * R11 状态横幅:六类失败与失效各有独立文案,不折叠成同一个错误。
* 可访问性:状态不只靠颜色 —— 每种状态都有可见文字;实时通知类(connecting /
* reconnecting)走 role="status",失败类(lost / machineOffline / revoked /
* loggedOut)走 role="alert"。connected 是正常态,不渲染横幅。
@@ -41,6 +41,17 @@ export default function SessionStatusBanner({
})
: t("session.status.machineOffline");
break;
+ case "desktopAppNotRunning":
+ copy =
+ machineLastSeenMs && machineLastSeenMs > 0
+ ? t("session.status.desktopAppNotRunningWithTime", {
+ time: new Date(machineLastSeenMs).toLocaleString(),
+ })
+ : t("session.status.desktopAppNotRunning");
+ break;
+ case "pinnedAgentredUnavailable":
+ copy = t("session.status.pinnedAgentredUnavailable");
+ break;
case "revoked":
copy = t("session.status.revoked");
break;
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index ede935e8..ab2a206c 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -203,7 +203,10 @@
"sessionCount": "{{count}} conversations",
"sessionWaiting": "{{count}} waiting for you",
"viewSessions": "View this machine's conversations",
+ "viewDesktopSessions": "View this desktop's conversations",
"offlineNotEnterable": "Offline — conversations are unavailable.",
+ "desktopAppNotRunningShort": "Agentre is not running",
+ "desktopAppNotRunningDetail": "Agentre is not running on this computer. Open Agentre to view its conversations.",
"cardSummary": "{{projects}} projects · {{m}} conversations running"
}
},
@@ -219,6 +222,9 @@
"lost": "Connection lost. Reconnecting automatically.",
"machineOffline": "This machine is offline.",
"machineOfflineWithTime": "This machine is offline — last seen {{time}}.",
+ "desktopAppNotRunning": "Agentre is not running on this computer. Open Agentre to continue.",
+ "desktopAppNotRunningWithTime": "Agentre is not running on this computer. Open Agentre to continue — last seen {{time}}.",
+ "pinnedAgentredUnavailable": "Conversation history is still available, but its pinned agentred is unavailable. New messages cannot be sent until agentred is available again.",
"revoked": "This browser has been revoked. Please sign in again.",
"loggedOut": "You have been signed out. Please sign in again."
},
@@ -240,6 +246,7 @@
"idle": "Idle",
"interrupted": "Interrupted",
"waiting": "Waiting for your input",
+ "historyIncomplete": "History is incomplete — showing only the part retained by agentred.",
"legacy": "{{cwd}} · {{backend}} · {{status}}",
"group": {
"waiting": "Waiting for you",
@@ -298,6 +305,7 @@
"pendingFrom": "Loading conversations from {{machine}}…",
"offlineTitle": "Offline",
"offlineMachineWithTime": "{{machine}} · offline · last seen {{time}}",
+ "desktopAppNotRunningWithTime": "{{machine}} · Agentre is not running · last seen {{time}}. Open Agentre to continue.",
"invalidTitle": "Unavailable",
"invalidBody": "This conversation is no longer reachable.",
"noMachine": "Unknown machine",
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index 44daa7f1..8cd8391d 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -203,7 +203,10 @@
"sessionCount": "{{count}} 条对话",
"sessionWaiting": "{{count}} 条等你处理",
"viewSessions": "查看这台机器的对话",
+ "viewDesktopSessions": "查看这个桌面端的对话",
"offlineNotEnterable": "已离线——对话不可用。",
+ "desktopAppNotRunningShort": "Agentre 未运行",
+ "desktopAppNotRunningDetail": "这台电脑上的 Agentre 没有运行。打开 Agentre 后即可查看它的对话。",
"cardSummary": "{{projects}} 个项目 · {{m}} 个对话在跑"
}
},
@@ -219,6 +222,9 @@
"lost": "连接已丢失,正在自动重连。",
"machineOffline": "这台机器已离线。",
"machineOfflineWithTime": "这台机器已离线——最后在线 {{time}}。",
+ "desktopAppNotRunning": "这台电脑上的 Agentre 没有运行。打开 Agentre 后即可继续。",
+ "desktopAppNotRunningWithTime": "这台电脑上的 Agentre 没有运行。打开 Agentre 后即可继续——最后在线 {{time}}。",
+ "pinnedAgentredUnavailable": "对话历史仍可阅读,但该会话钉住的 agentred 当前不可用。agentred 恢复前无法发送新消息。",
"revoked": "该浏览器已被解除授权,请重新登录。",
"loggedOut": "账号已登出,请重新登录。"
},
@@ -240,6 +246,7 @@
"idle": "空闲",
"interrupted": "已中断",
"waiting": "正在等待你的输入",
+ "historyIncomplete": "历史不完整——当前只显示 agentred 保留的部分。",
"legacy": "{{cwd}} · {{backend}} · {{status}}",
"group": {
"waiting": "等你处理",
@@ -298,6 +305,7 @@
"pendingFrom": "正在加载 {{machine}} 上的对话…",
"offlineTitle": "离线",
"offlineMachineWithTime": "{{machine}} · 离线 · 最后在线 {{time}}",
+ "desktopAppNotRunningWithTime": "{{machine}} · Agentre 未运行 · 最后在线 {{time}}。打开 Agentre 后即可继续。",
"invalidTitle": "已不可用",
"invalidBody": "这条对话已经找不到目标了。",
"noMachine": "未知机器",
diff --git a/frontend/src/lib/sessionMerge.ts b/frontend/src/lib/sessionMerge.ts
new file mode 100644
index 00000000..de1749bb
--- /dev/null
+++ b/frontend/src/lib/sessionMerge.ts
@@ -0,0 +1,50 @@
+import type { SessionSummary } from "@/lib/wire";
+
+export interface SessionCopy {
+ sourceFingerprint: string;
+ sourceKind: string;
+ summary: SessionSummary;
+ value?: T;
+}
+
+export interface MergedSessionCopy extends SessionCopy {
+ historyIncomplete: boolean;
+}
+
+function mergeKey(copy: SessionCopy, index: number): string {
+ const peer = copy.summary.peerFingerprint?.trim();
+ if (!peer) {
+ // 没有发起端指纹时不能只按 SessionID 合并:不同发起端可合法地产生同号会话。
+ return `unmergeable\u0000${copy.sourceFingerprint}\u0000${copy.summary.sessionId}\u0000${index}`;
+ }
+ return `${peer}\u0000${copy.summary.sessionId}`;
+}
+
+/**
+ * R20:按(发起端指纹,SessionID)合并同一会话的多份清单摘要。
+ * 桌面端持有完整标题与转录,因此无论输入顺序都优先;只有桌面端副本不在场时才
+ * 保留 agentred 副本。agentred 副本属于某个已知桌面发起端时,明确标记历史不完整。
+ */
+export function mergeSessionCopies(
+ copies: SessionCopy[],
+ desktopFingerprints: ReadonlySet = new Set(),
+): MergedSessionCopy[] {
+ const byKey = new Map>();
+ copies.forEach((copy, index) => {
+ const key = mergeKey(copy, index);
+ const current = byKey.get(key);
+ if (
+ !current ||
+ (copy.sourceKind === "desktop" && current.sourceKind !== "desktop")
+ ) {
+ byKey.set(key, copy);
+ }
+ });
+
+ return [...byKey.values()].map((copy) => ({
+ ...copy,
+ historyIncomplete:
+ copy.sourceKind === "agentred" &&
+ desktopFingerprints.has(copy.summary.peerFingerprint?.trim() ?? ""),
+ }));
+}
diff --git a/frontend/src/lib/sessionView.ts b/frontend/src/lib/sessionView.ts
index 5c7c45b7..ecc97ae3 100644
--- a/frontend/src/lib/sessionView.ts
+++ b/frontend/src/lib/sessionView.ts
@@ -1,17 +1,18 @@
/**
- * 会话视图状态(R11):四类不可达与失效各自可区分,不折叠成同一个错误。
+ * 会话视图状态(R11):六类失败与失效各自可区分,不折叠成同一个错误。
*
* 由纯函数 deriveSessionViewStatus 从底层信号导出,便于 vitest 直接构造各状态断言
- * 文案互不相同(测试接缝 6)。四类状态:
- * - machineOffline —— 目标 agentred 离线(R11 第 1 类)
- * - reconnecting / lost / connected —— 浏览器与 server 之间断线(R11 第 2 类,
- * 按既有的 connected / reconnecting / lost 表达)
- * - revoked —— 本浏览器已被解除授权,须重新登录(R11 第 3 类)
- * - loggedOut —— 账号已登出(R11 第 4 类)
+ * 文案互不相同(测试接缝 6)。六类失败/失效状态:
+ * - machineOffline —— 目标 agentred 离线(既有措辞保持不变)
+ * - reconnecting / lost / connected —— 浏览器与 server 之间断线
+ * - revoked —— 本浏览器已被解除授权,须重新登录
+ * - loggedOut —— 账号已登出
+ * - desktopAppNotRunning —— 目标桌面端存在,但 Agentre App 没运行
+ * - pinnedAgentredUnavailable —— 桌面端在场且历史可读,但新写入无法送到钉住的 agentred
*
- * 优先级:账号登出 > 解除授权 > 机器离线 > 中继连接态。优先级由可探测性决定:
- * 账号没了其余全无意义;设备被解除授权后任何注册 / 连接都不该继续;机器离线时
- * 中继必然连不上,没必要先演一段「连接中」。
+ * 优先级:账号登出 > 解除授权 > 目标不可达 > 中继连接态 > 钉住的 agentred 不可写。
+ * 账号没了其余全无意义;设备被解除授权后任何注册 / 连接都不该继续;目标不可达时
+ * 中继必然连不上;钉住的 agentred 状态只在桌面中继仍连接时成立。
*/
import type { RelayState } from "@/lib/relayClient";
@@ -21,6 +22,8 @@ export type SessionViewStatus =
| "reconnecting"
| "lost"
| "machineOffline"
+ | "desktopAppNotRunning"
+ | "pinnedAgentredUnavailable"
| "revoked"
| "loggedOut";
@@ -31,17 +34,32 @@ export interface SessionViewInput {
meValid: boolean;
/** 本浏览器(kind=web 设备)是否已被解除授权。 */
webDeviceRevoked: boolean;
- /** 目标 agentred 是否在线;null = 尚未探知。 */
+ /** 目标设备是否在线;desktop=false 表示 App 没运行,agentred=false 表示机器离线。 */
machineOnline: boolean | null;
+ targetKind?: "agentred" | "desktop" | string;
+ /** 桌面端仍可读历史,但该会话钉住的 agentred 当前不可用于新写入。 */
+ pinnedAgentredUnavailable?: boolean;
}
export function deriveSessionViewStatus(
input: SessionViewInput,
): SessionViewStatus {
- const { relayState, meValid, webDeviceRevoked, machineOnline } = input;
+ const {
+ relayState,
+ meValid,
+ webDeviceRevoked,
+ machineOnline,
+ targetKind,
+ pinnedAgentredUnavailable,
+ } = input;
if (!meValid) return "loggedOut";
if (webDeviceRevoked) return "revoked";
- if (machineOnline === false) return "machineOffline";
+ if (machineOnline === false) {
+ return targetKind === "desktop" ? "desktopAppNotRunning" : "machineOffline";
+ }
+ if (relayState === "connected" && pinnedAgentredUnavailable) {
+ return "pinnedAgentredUnavailable";
+ }
switch (relayState) {
case "connected":
return "connected";
diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx
index 706c7f8a..44f6f83f 100644
--- a/frontend/src/pages/Chat.tsx
+++ b/frontend/src/pages/Chat.tsx
@@ -19,6 +19,7 @@ import SessionDetailView from "@/components/session/SessionDetailView";
import { useIsMobile } from "@/components/use-is-mobile";
import { useRelayMachine } from "@/hooks/use-relay";
import { api, ApiError } from "@/lib/api";
+import { mergeSessionCopies } from "@/lib/sessionMerge";
import { decodeSessionListResult, type SessionSummary } from "@/lib/wire";
/**
@@ -31,7 +32,7 @@ import { decodeSessionListResult, type SessionSummary } from "@/lib/wire";
*
* 关注名单(/v1/follows)只含「指向」(设备指纹 + 会话标识 + 关注时间),不含
* 标题 / 消息 / 转录。要显示与 R5 同一套信息(标题 / 状态 / 等待输入),本页对
- * 每个在线的目标机器挂一个 FollowedMachineResolver:连上中继后 session.list,
+ * 每个在线的目标设备挂一个 FollowedMachineResolver:连上中继后 session.list,
* 把关注到的会话解析出来。机器离线时该条仍在名单里并标明离线(R13);目标已
* 不存在(设备被撤销 / 机器上已没有这条会话)时标失效、可一键移除。
*/
@@ -63,8 +64,8 @@ function loadErrorText(e: unknown, t: (k: string) => string): string {
}
/**
- * 一台在线目标机器的会话解析器:用 useRelayMachine 连到那台 agentred,连上后
- * session.list,把属于本页关注集合的会话解析出来回传。每台在线机器一个实例。
+ * 一台在线目标设备的会话解析器:用 useRelayMachine 连到桌面端或 agentred,连上后
+ * session.list,把属于本页关注集合的会话解析出来回传。每台在线目标一个实例。
*/
function FollowedMachineResolver({
fingerprint,
@@ -233,9 +234,84 @@ export default function Chat() {
const pending: ChatPendingRow[] = [];
const offline: ChatOfflineRow[] = [];
const invalid: ChatInvalidRow[] = [];
+ const desktopFingerprints = new Set(
+ devices
+ .filter((device) => device.kind === "desktop")
+ .map((device) => device.fingerprint),
+ );
+
+ const copies = follows.flatMap((follow) => {
+ const device = devicesByFp.get(follow.device_fingerprint);
+ if (
+ follow.invalid ||
+ !device?.online ||
+ machineState[follow.device_fingerprint] === "unreachable"
+ ) {
+ return [];
+ }
+ const match = (resolved[follow.device_fingerprint] ?? []).find(
+ (session) => String(session.sessionId) === follow.session_id,
+ );
+ return match
+ ? [
+ {
+ sourceFingerprint: follow.device_fingerprint,
+ sourceKind: device.kind,
+ summary: match,
+ value: { follow, device },
+ },
+ ]
+ : [];
+ });
+ const merged = mergeSessionCopies(copies, desktopFingerprints);
+ const resolvedFollowKeys = new Set(
+ copies.map(
+ (copy) =>
+ `${copy.value.follow.device_fingerprint}:${copy.value.follow.session_id}`,
+ ),
+ );
+ const selectedFollowKeys = new Set(
+ merged.map(
+ (copy) =>
+ `${copy.value!.follow.device_fingerprint}:${copy.value!.follow.session_id}`,
+ ),
+ );
+ const representedKeys = new Set(
+ merged.flatMap((copy) => {
+ const peer = copy.summary.peerFingerprint?.trim();
+ return peer ? [`${peer}\u0000${copy.summary.sessionId}`] : [];
+ }),
+ );
+
+ for (const copy of merged) {
+ const { follow, device } = copy.value!;
+ const syncId = copy.summary.agentSyncId?.trim() ?? "";
+ const row: ChatSessionRow = {
+ key: `${follow.device_fingerprint}:${follow.session_id}`,
+ fingerprint: follow.device_fingerprint,
+ sessionId: copy.summary.sessionId,
+ deviceId: device.id,
+ deviceName: device.name,
+ followedAt: follow.followed_at,
+ summary: copy.summary,
+ historyIncomplete: copy.historyIncomplete,
+ };
+ let group: ChatGroup | undefined;
+ if (syncId) {
+ group = groupsByKey.get(syncId);
+ if (!group) {
+ group = { key: syncId, label: syncId, sessions: [] };
+ groupsByKey.set(syncId, group);
+ }
+ } else {
+ group = unnamed;
+ }
+ group.sessions.push(row);
+ }
for (const f of follows) {
const key = `${f.device_fingerprint}:${f.session_id}`;
+ if (selectedFollowKeys.has(key) || resolvedFollowKeys.has(key)) continue;
const device = devicesByFp.get(f.device_fingerprint);
if (f.invalid || !device) {
invalid.push({
@@ -247,6 +323,13 @@ export default function Chat() {
});
continue;
}
+ // agentred 退化副本已经代表了同一条桌面会话时,不再额外画一条桌面离线/等待行。
+ if (
+ device.kind === "desktop" &&
+ representedKeys.has(`${device.fingerprint}\u0000${f.session_id}`)
+ ) {
+ continue;
+ }
if (
!device.online ||
machineState[f.device_fingerprint] === "unreachable"
@@ -257,48 +340,23 @@ export default function Chat() {
sessionId: Number(f.session_id),
deviceId: device.id,
deviceName: device.name,
+ deviceKind: device.kind,
lastSeenAt: device.last_seen_at,
});
continue;
}
- const sessList = resolved[f.device_fingerprint] ?? [];
- const match = sessList.find((s) => String(s.sessionId) === f.session_id);
- if (!match) {
- if (machineState[f.device_fingerprint] === "connected") {
- // 已连上但机器上已没有这条会话(如 daemon 清理了记录)→ 失效可移除。
- invalid.push({
- key,
- fingerprint: f.device_fingerprint,
- sessionId: Number(f.session_id),
- deviceName: device.name,
- reason: "session",
- });
- } else {
- pending.push({ key, deviceName: device.name });
- }
- continue;
- }
- const syncId = match.agentSyncId?.trim() ?? "";
- const row: ChatSessionRow = {
- key,
- fingerprint: f.device_fingerprint,
- sessionId: match.sessionId,
- deviceId: device.id,
- deviceName: device.name,
- followedAt: f.followed_at,
- summary: match,
- };
- let group: ChatGroup | undefined;
- if (syncId) {
- group = groupsByKey.get(syncId);
- if (!group) {
- group = { key: syncId, label: syncId, sessions: [] };
- groupsByKey.set(syncId, group);
- }
+ if (machineState[f.device_fingerprint] === "connected") {
+ // 已连上但机器上已没有这条会话(如 daemon 清理了记录)→ 失效可移除。
+ invalid.push({
+ key,
+ fingerprint: f.device_fingerprint,
+ sessionId: Number(f.session_id),
+ deviceName: device.name,
+ reason: "session",
+ });
} else {
- group = unnamed;
+ pending.push({ key, deviceName: device.name });
}
- group.sessions.push(row);
}
const groups = [...groupsByKey.values()];
@@ -312,7 +370,7 @@ export default function Chat() {
const total =
groups.reduce((n, g) => n + g.sessions.length, 0) + offline.length;
return { groups, pending, offline, invalid, total };
- }, [follows, devicesByFp, agents, resolved, machineState, t]);
+ }, [follows, devices, devicesByFp, agents, resolved, machineState, t]);
if (loadError) {
return (
@@ -543,7 +601,7 @@ export default function Chat() {
}
/>
- {/* 每个在线且有非失效关注的机器挂一个解析器(见文件头注释)。 */}
+ {/* 每个在线且有非失效关注的目标设备挂一个解析器(见文件头注释)。 */}
{loaded &&
[...devicesByFp.values()]
.filter(
diff --git a/frontend/src/pages/DeviceSessions.tsx b/frontend/src/pages/DeviceSessions.tsx
index 1b120cad..375dd9db 100644
--- a/frontend/src/pages/DeviceSessions.tsx
+++ b/frontend/src/pages/DeviceSessions.tsx
@@ -56,7 +56,7 @@ export default function DeviceSessions() {
const nav = useNavigate();
const [device, setDevice] = useState(null);
- // 账号下的全部 agentred:面包屑行尾的「换机器」就地切换(决策 11 / 帧 45a)要用。
+ // 账号下全部可下钻目标:面包屑行尾的「换机器」就地切换要用。
const [machines, setMachines] = useState([]);
const [switchOpen, setSwitchOpen] = useState(false);
const [deviceError, setDeviceError] = useState(null);
@@ -72,7 +72,7 @@ export default function DeviceSessions() {
const probedRef = useRef(false);
const { client, relayState, webDevice, webDeviceError } = useRelayMachine(
- device?.fingerprint ?? null,
+ device?.online ? device.fingerprint : null,
);
const isMobile = useIsMobile();
@@ -85,8 +85,12 @@ export default function DeviceSessions() {
])
.then(([devRes, agentRes]) => {
if (!alive) return;
- // 浏览器不是可下钻的目标(R3 / 帧 47),只留 agentred。
- setMachines(devRes.devices.filter((d) => d.kind === "agentred"));
+ // 浏览器不是可下钻目标;agentred 与桌面端复用同一套列表/详情页面。
+ setMachines(
+ devRes.devices.filter(
+ (d) => d.kind === "agentred" || d.kind === "desktop",
+ ),
+ );
const found = devRes.devices.find((d) => d.id === id);
if (found) {
setDevice(found);
@@ -137,9 +141,10 @@ export default function DeviceSessions() {
if (!alive) return;
const res = decodeSessionListResult(raw);
setSessions(res.sessions);
- // 老 agentred 不认识这个字段 → 它落库不了 R7 / 决策 8 的那几列,会话只能
- // 退化显示、发新消息也续不上上下文。如实说明,不静默失败。
- setNeedsUpgrade(!res.supportsSessionMetadata);
+ // 老 agentred 不认识这个字段;桌面端清单由本轮实现,始终是完整形态。
+ setNeedsUpgrade(
+ device?.kind === "agentred" && !res.supportsSessionMetadata,
+ );
setSessionsLoaded(true);
})
.catch(() => {
@@ -148,7 +153,7 @@ export default function DeviceSessions() {
return () => {
alive = false;
};
- }, [client, relayState]);
+ }, [client, relayState, device?.kind]);
// 首次进入 reconnecting(= 连接失败)时探测原因:机器离线 / 设备被解除授权 /
// 账号登出。只探一次(ref 守门,不触发重渲染)。
@@ -219,6 +224,7 @@ export default function DeviceSessions() {
meValid,
webDeviceRevoked: revokedNow,
machineOnline,
+ targetKind: device?.kind,
});
if (deviceError) {
@@ -256,7 +262,11 @@ export default function DeviceSessions() {
}
>
{machineOnline === false
- ? t("session.breadcrumb.offline")
+ ? t(
+ device?.kind === "desktop"
+ ? "device.manage.desktopAppNotRunningShort"
+ : "session.breadcrumb.offline",
+ )
: t("session.breadcrumb.online")}
>
@@ -276,7 +286,11 @@ export default function DeviceSessions() {
}
>
{machineOnline === false
- ? t("session.breadcrumb.offline")
+ ? t(
+ device?.kind === "desktop"
+ ? "device.manage.desktopAppNotRunningShort"
+ : "session.breadcrumb.offline",
+ )
: t("session.breadcrumb.online")}
{count > 0 && (
@@ -328,7 +342,11 @@ export default function DeviceSessions() {
{m.name}
- {t("session.breadcrumb.offline")}
+ {t(
+ m.kind === "desktop"
+ ? "device.manage.desktopAppNotRunningShort"
+ : "session.breadcrumb.offline",
+ )}
)}
diff --git a/frontend/src/pages/Devices.tsx b/frontend/src/pages/Devices.tsx
index 7b2dea33..365019d7 100644
--- a/frontend/src/pages/Devices.tsx
+++ b/frontend/src/pages/Devices.tsx
@@ -66,6 +66,7 @@ interface SessionCounts {
const ACTIVE = 1;
const KIND_AGENTRED = "agentred";
+const KIND_DESKTOP = "desktop";
// 只有 ApiError 才带可展示的服务端文案;其余(代理返回非 JSON 的 502 → SyntaxError、
// 离线 → TypeError)同样是失败,必须说出来 —— 静默吞掉会让页面渲染成「还没有任何
@@ -98,7 +99,9 @@ function statusLabel(d: DeviceItem, t: (key: string) => string): string {
if (d.status !== ACTIVE) return t("device.manage.statusRevoked");
return d.online
? t("device.manage.statusOnline")
- : t("device.manage.statusOffline");
+ : d.kind === KIND_DESKTOP
+ ? t("device.manage.desktopAppNotRunningShort")
+ : t("device.manage.statusOffline");
}
/** 设备是否可撤销:撤销端点只认属于本账号的 ACTIVE 设备;已撤销的没有凭据可撤。 */
@@ -107,9 +110,9 @@ function isRevocable(d: DeviceItem): boolean {
}
/**
- * 一台 agentred 的会话计数(总条数 / 等待处理 / 在跑)。server 一条会话都不存
- * (硬不变量),唯一真相源是那台 agentred 的 relay session.list —— 现连现问,
- * 展开时才挂载,收起即断开;问不到就返回 null,调用方不得编数字。
+ * 一台目标设备(桌面端或 agentred)的会话计数(总条数 / 等待处理 / 在跑)。
+ * server 一条会话都不存(硬不变量),唯一真相源是那台设备的 relay session.list ——
+ * 现连现问,展开时才挂载,收起即断开;问不到就返回 null,调用方不得编数字。
*
* 「在跑」数是设备卡副行 cardSummary 的 {{m}};「对话」一节用 total/waiting。
*/
@@ -146,10 +149,10 @@ function useSessionCounts(
/**
* 设备行展开的详情:agentred 列「能跑的 Agent」(带档位)、「已配置的项目」与
- * 「对话」一节(R4 下钻入口,mockup 帧 47);桌面端只列「项目」。
- * 对话一节:在线的 agentred 给「查看这台机器的对话」入口;离线的不可进入,
- * 就地标明离线与最后在线时间(R4)。会话计数由外层 useSessionCounts 提供,
- * 这里不再各自连一次中继。
+ * 「对话」一节(R4 下钻入口,mockup 帧 47);桌面端列项目。
+ * 对话一节:两类目标复用同形入口 —— 在线给「查看这台机器 / 这个桌面端的对话」;
+ * 不可达时 agentred 保留既有离线措辞,桌面端明确说明 Agentre App 没有运行并提示打开它。
+ * 会话计数由外层 useSessionCounts 提供,这里不再各自连一次中继。
*
* 只渲染真实数据;N1/N2 旁白(「只显示是否配置」「Agent 属于账号」)不进入产品。
*/
@@ -177,6 +180,7 @@ function DeviceExpandDetail({
const detail = state.data;
if (!detail) return null;
const isAgentred = detail.kind === KIND_AGENTRED;
+ const isDesktop = detail.kind === KIND_DESKTOP;
return (
@@ -237,7 +241,7 @@ function DeviceExpandDetail({
)}
- {isAgentred && (
+ {(isAgentred || isDesktop) && (
{t("device.manage.sessions")}
@@ -263,12 +267,20 @@ function DeviceExpandDetail({
data-testid={`device-sessions-link-${device.id}`}
className="inline-flex w-fit items-center text-xs font-medium text-primary-text hover:underline"
>
- {t("device.manage.viewSessions")}
+ {t(
+ isDesktop
+ ? "device.manage.viewDesktopSessions"
+ : "device.manage.viewSessions",
+ )}
>
) : (
- {t("device.manage.offlineNotEnterable")}
+ {t(
+ isDesktop
+ ? "device.manage.desktopAppNotRunningDetail"
+ : "device.manage.offlineNotEnterable",
+ )}
{device.last_seen_at > 0
? ` ${new Date(device.last_seen_at).toLocaleString()}`
: ""}
@@ -316,8 +328,11 @@ function DeviceRow({
detailState: { loading: boolean; error: unknown; data: DeviceDetail | null };
t: (key: string, opts?: Record) => string;
}) {
- // 只有展开的在线 agentred 才去问中继;其余设备 fingerprint 传 null,不连。
- const sessionActive = isExpanded && d.kind === KIND_AGENTRED && d.online;
+ // 只有展开的在线 agentred / 桌面端才去问中继;其余设备 fingerprint 传 null,不连。
+ const sessionActive =
+ isExpanded &&
+ (d.kind === KIND_AGENTRED || d.kind === KIND_DESKTOP) &&
+ d.online;
const counts = useSessionCounts(
sessionActive ? d.fingerprint : null,
sessionActive,
From 54f5e88f3fcca9a59e86a662de66a70844eece33 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?=
Date: Wed, 12 Aug 2026 00:23:44 +0800
Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=A7=AA=20=E5=89=8D=E7=AB=AF:=20?=
=?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=A1=8C=E9=9D=A2=E7=AB=AF=E6=9C=AA=E8=BF=90?=
=?UTF-8?q?=E8=A1=8C=E7=8A=B6=E6=80=81=E6=96=AD=E8=A8=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/__tests__/device-management.test.tsx | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/frontend/src/__tests__/device-management.test.tsx b/frontend/src/__tests__/device-management.test.tsx
index 0d01a034..dd30edbc 100644
--- a/frontend/src/__tests__/device-management.test.tsx
+++ b/frontend/src/__tests__/device-management.test.tsx
@@ -95,11 +95,13 @@ describe("device management page", () => {
expect(screen.getByText(/linux/)).toBeTruthy();
expect(screen.getByText(/darwin/)).toBeTruthy();
// 在线态列来自 API 的 online 字段(真实中继在线态),不是 status 推算(R20):
- // 逐行按 API 给的 online 值断言,而不是全页面找一个 "Online" 就算数。
+ // 逐行断言;desktop 离线要表达 App 未运行,而不是笼统的机器离线。
const nucCard = screen.getByText("nuc-01").closest('[data-slot="card"]');
const laptopCard = screen.getByText("laptop").closest('[data-slot="card"]');
expect(within(nucCard as HTMLElement).getByText(/Online/)).toBeTruthy();
- expect(within(laptopCard as HTMLElement).getByText(/Offline/)).toBeTruthy();
+ expect(
+ within(laptopCard as HTMLElement).getByText(/Agentre is not running/),
+ ).toBeTruthy();
});
it("revoke confirmation carries the R4 delay note, then revokes and removes the device", async () => {
From 3e3d06cc4aa33ce721f5684b774f0ba39395470f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?=
Date: Wed, 12 Aug 2026 12:49:38 +0800
Subject: [PATCH 4/5] =?UTF-8?q?=E2=9C=A8=20web=20=E6=B4=BE=E5=8F=91?=
=?UTF-8?q?=E8=AE=A1=E5=88=92=E6=94=AF=E6=8C=81=E6=A1=8C=E9=9D=A2=E7=AB=AF?=
=?UTF-8?q?=E7=9B=AE=E6=A0=87=E5=B9=B6=E5=A6=82=E5=AE=9E=E8=AF=B4=E6=98=8E?=
=?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=8F=AF=E7=94=A8=E6=80=A7=EF=BC=88=E4=BB=BB?=
=?UTF-8?q?=E5=8A=A1=209=20R17=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- WebDispatchPlan 为桌面端档解析本机上报路径(device_local_paths),
不再误判 project_path_missing,cwd 用桌面端自己的路径;Tier/Choice 带 kind。
- 发起前(确认步)按目标种类如实说明 org/subagent/hook 是否可用:桌面端可用,
agentred 不可用(轮 A R17 原样成立)。
- 测试:桌面端首档可用取本机路径并带 kind、桌面端缺项目路径逐档原因并跳过、
dispatch/NewConversation 双态文案。
---
frontend/src/__tests__/dispatch.test.ts | 80 +++++++++++++
.../src/__tests__/new-conversation.test.tsx | 60 ++++++++++
.../session/NewConversationDialog.tsx | 38 +++---
frontend/src/i18n/locales/en.json | 2 +
frontend/src/i18n/locales/zh-CN.json | 2 +
frontend/src/lib/dispatch.ts | 4 +
internal/api/workspace/workspace.go | 7 +-
.../controller/workspace_ctr/workspace.go | 4 +-
internal/service/workspace_svc/workspace.go | 52 ++++++++-
.../service/workspace_svc/workspace_test.go | 108 ++++++++++++++++++
10 files changed, 337 insertions(+), 20 deletions(-)
diff --git a/frontend/src/__tests__/dispatch.test.ts b/frontend/src/__tests__/dispatch.test.ts
index 74ac651e..3fdadbe7 100644
--- a/frontend/src/__tests__/dispatch.test.ts
+++ b/frontend/src/__tests__/dispatch.test.ts
@@ -53,6 +53,7 @@ const availablePlan: DispatchPlan = {
device_id: 21,
device_name: "公司 Mac mini",
backend_type: "codex",
+ kind: "agentred",
availability: "available",
current: true,
},
@@ -62,11 +63,38 @@ const availablePlan: DispatchPlan = {
device_id: 21,
device_name: "公司 Mac mini",
backend_type: "codex",
+ kind: "agentred",
cwd: "/srv/agentre-server",
},
projects: [],
};
+// R17:第一档可用的是桌面端时,派发计划选中它(kind=desktop),发起前界面据此
+// 说明 org/subagent/hook 可用(见 NewConversationDialog)。逻辑层照常用 runtime.run
+// 把对话建到那台桌面端上,不注入 mcpServers。
+const desktopPlan: DispatchPlan = {
+ ...availablePlan,
+ tiers: [
+ {
+ rank: 1,
+ device_id: 30,
+ device_name: "家里 Mac mini",
+ backend_type: "claudecode",
+ kind: "desktop",
+ availability: "available",
+ current: true,
+ },
+ ],
+ chosen: {
+ device_fingerprint: "fp-desk",
+ device_id: 30,
+ device_name: "家里 Mac mini",
+ backend_type: "claudecode",
+ kind: "desktop",
+ cwd: "/Users/wyz/agentre-server",
+ },
+};
+
const allUnavailablePlan: DispatchPlan = {
agent_sync_id: "agent-1",
tiers: [
@@ -219,6 +247,58 @@ describe("dispatchNewConversation(R15 派发 + R16 自关注)", () => {
expect(mockedApi).not.toHaveBeenCalled();
});
+ // R17:目标是桌面端时,同一套 runtime.run 把对话建到那台桌面端上——带着第一句、
+ // 发起方身份与那台机器上的项目路径;不注入 mcpServers(org/subagent/hook 是桌面端
+ // 本机内置工具,可用性由发起前的界面按 kind=desktop 如实说明,不由浏览器注入)。
+ it("目标是桌面端时向桌面端发 runtime.run 建会话(不注入 mcpServers)", async () => {
+ const client = fakeClient();
+ MockRelayClient.mockImplementation((() => client) as never);
+
+ const out = await dispatchNewConversation({
+ plan: desktopPlan,
+ message: "帮我看看这个项目",
+ sourceDevice,
+ });
+
+ const constructorOpts = MockRelayClient.mock.calls[0][0] as {
+ url: string;
+ jwt: string;
+ reconnect: boolean;
+ };
+ expect(constructorOpts.url).toContain("fp-desk");
+ expect(constructorOpts.jwt).toBe("web-jwt");
+ expect(constructorOpts.reconnect).toBe(false);
+
+ const [method, params] = client.request.mock.calls[0];
+ const p = params as Record;
+ expect(method).toBe(MethodRun);
+ expect(p.sessionId).toBeGreaterThan(0);
+ expect(p.agentSyncId).toBe("agent-1");
+ expect(p.cwd).toBe("/Users/wyz/agentre-server");
+ expect(p.userText).toBe("帮我看看这个项目");
+ expect(p.title).toBe("帮我看看这个项目");
+ expect(p.backend).toEqual({ type: "claudecode" });
+ expect(p.sourceDevice).toBe("fp-web");
+ expect(p.sourceDeviceName).toBe("Chrome · macOS");
+ expect(p.mcpServers).toBeUndefined();
+
+ // R16:桌面端建会话成功后同样立刻关注自己这条。
+ expect(mockedApi).toHaveBeenCalledWith("/v1/follows", {
+ method: "POST",
+ body: JSON.stringify({
+ device_fingerprint: "fp-desk",
+ session_id: "9001",
+ }),
+ });
+
+ expect(out).toEqual({
+ sessionId: 9001,
+ deviceId: 30,
+ deviceFingerprint: "fp-desk",
+ });
+ expect(client.close).toHaveBeenCalled();
+ });
+
// R16 的自关注是**派发成功之后**的收尾动作:runtime.run 一旦返回 ack,那台机器上
// 就已经真真切切多了一条会话。此时把关注写失败(网络抖动 / server 500)报成派发
// 失败,界面会说「联系不上这台机器,请重试」——用户一重试就凭空又开一条真会话,
diff --git a/frontend/src/__tests__/new-conversation.test.tsx b/frontend/src/__tests__/new-conversation.test.tsx
index 2423dc89..c66e8cc1 100644
--- a/frontend/src/__tests__/new-conversation.test.tsx
+++ b/frontend/src/__tests__/new-conversation.test.tsx
@@ -99,10 +99,36 @@ const finalPlan: DispatchPlan = {
device_id: 21,
device_name: "公司 Mac mini",
backend_type: "codex",
+ kind: "agentred",
cwd: "/srv/agentre-server",
},
};
+// R17:目标是桌面端时,发起前如实说明 org / subagent / hook **可用**(真身在桌面端)。
+const desktopFinalPlan: DispatchPlan = {
+ agent_sync_id: "agent-1",
+ tiers: [
+ {
+ rank: 1,
+ device_id: 30,
+ device_name: "家里 Mac mini",
+ backend_type: "claudecode",
+ kind: "desktop",
+ availability: "available",
+ current: true,
+ },
+ ],
+ chosen: {
+ device_fingerprint: "fp-desk",
+ device_id: 30,
+ device_name: "家里 Mac mini",
+ backend_type: "claudecode",
+ kind: "desktop",
+ cwd: "/Users/wyz/agentre-server",
+ },
+ projects: [{ sync_id: "proj-1", name: "agentre-server", configured: true }],
+};
+
const allUnavailablePlan: DispatchPlan = {
agent_sync_id: "agent-1",
tiers: [
@@ -248,6 +274,40 @@ describe("新对话弹层:R17 发起前说明 + R16 派发后自关注", () => {
);
});
+ it("目标是桌面端时,确认步如实说明 org / subagent / hook 可用(不是沿用 agentred 的不可用文案)", async () => {
+ mockedApi.mockImplementation(async (path) => {
+ if (path === "/v1/workspace/agents") return { agents };
+ throw new Error("unexpected: " + path);
+ });
+ mockFetchPlan.mockImplementation(async (_agent, project) =>
+ project ? desktopFinalPlan : pickPlan,
+ );
+ mockEnsureWebDevice.mockResolvedValue(webDevice);
+ mockDispatch.mockResolvedValue({
+ sessionId: 9002,
+ deviceId: 30,
+ deviceFingerprint: "fp-desk",
+ });
+ const onStarted = vi.fn();
+ renderDialog(onStarted);
+
+ fireEvent.click(await screen.findByText("后端 Agent"));
+ fireEvent.click(await screen.findByText("agentre-server"));
+
+ // R17:目标是桌面端 → org / subagent / hook 可用(真身就在那台机器上)。
+ expect(
+ await screen.findByText("org / subagent / hook are available here"),
+ ).toBeTruthy();
+ // 不可用的文案不得出现。
+ expect(
+ screen.queryByText("org / subagent / hook are not available here"),
+ ).toBeNull();
+ // 屏 25:将运行在桌面端机器 · 路径。
+ expect(
+ screen.getByText("Will run on 家里 Mac mini · /Users/wyz/agentre-server"),
+ ).toBeTruthy();
+ });
+
it("不输入第一句时「开始」按钮是禁用的(发出第一条消息之前什么都不会跑)", async () => {
mockedApi.mockImplementation(async (path) => {
if (path === "/v1/workspace/agents") return { agents };
diff --git a/frontend/src/components/session/NewConversationDialog.tsx b/frontend/src/components/session/NewConversationDialog.tsx
index 48fe51ce..f89a31bb 100644
--- a/frontend/src/components/session/NewConversationDialog.tsx
+++ b/frontend/src/components/session/NewConversationDialog.tsx
@@ -6,8 +6,9 @@
* - R15:派发计划(/v1/workspace/dispatch-target)按序列出执行目标链每一档的
* 原因(本机跳过 / 未配对 / 离线 / 项目路径缺失),只有第一档可用的 agentred
* 才能继续;全部不可用时不静默失败,逐档把原因摆给用户(屏 24 的「现在选不了」)。
- * - R17:发起前就在确认步说明 org / subagent / hook 用不了以及原因——不是等
- * 工具调用失败了才报错。
+ * - R17:发起前就在确认步说明 org / subagent / hook 在当前目标下是否可用——目标是
+ * 桌面端时这三个内置工具的真身就在那台机器上、可用;是 agentred 时轮 A 的 R17
+ * 原样成立、不可用。不是等工具调用失败了才报错。
* - R16:确认后经 lib/dispatch.dispatchNewConversation 派发并立刻关注自己这条,
* 于是它不经「关注」就出现在「对话」页。
*/
@@ -360,18 +361,27 @@ export default function NewConversationDialog({
})}
)}
- {/* R17:发起前就说明 org / subagent / hook 用不了以及原因。 */}
-
-
- {t("chat.r17Title")}
-
-
- {t("chat.r17Body")}
-
-
+ {/* R17:发起前如实说明当前目标下 org / subagent / hook 是否可用——目标是
+ 桌面端时这三个内置工具的真身就在那台机器上、可用;是 agentred 时
+ 轮 A 的 R17 原样成立、不可用。不是等工具调用失败了才报错。 */}
+ {confirmChoice && (
+
+
+ {confirmChoice.kind === "desktop"
+ ? t("chat.r17DesktopTitle")
+ : t("chat.r17Title")}
+
+
+ {confirmChoice.kind === "desktop"
+ ? t("chat.r17DesktopBody")
+ : t("chat.r17Body")}
+
+
+ )}