From 85090bf7e41dc041ccee5983085b51b4b12db923 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 23 Jun 2026 16:30:06 +0800 Subject: [PATCH 1/3] Improve resources list and sidebar divider --- .../skills/bricks-local-cloud-dev/SKILL.md | 133 ++++++++++++++++++ .../features/chat/chat_navigation_page.dart | 50 +++++-- .../lib/features/chat/chat_screen.dart | 18 ++- .../test/chat_navigation_page_test.dart | 41 +++++- docs/code_maps/feature_map.yaml | 2 + docs/code_maps/logic_map.yaml | 3 + ...-15-17-CST-bricks-local-cloud-dev-skill.md | 38 +++++ ...5-51-CST-resource-item-meta-icon-layout.md | 41 ++++++ ...-CST-desktop-sidebar-divider-visibility.md | 35 +++++ 9 files changed, 341 insertions(+), 20 deletions(-) create mode 100644 .agents/skills/bricks-local-cloud-dev/SKILL.md create mode 100644 docs/plans/2026-06-23-15-17-CST-bricks-local-cloud-dev-skill.md create mode 100644 docs/plans/2026-06-23-15-51-CST-resource-item-meta-icon-layout.md create mode 100644 docs/plans/2026-06-23-16-24-CST-desktop-sidebar-divider-visibility.md diff --git a/.agents/skills/bricks-local-cloud-dev/SKILL.md b/.agents/skills/bricks-local-cloud-dev/SKILL.md new file mode 100644 index 00000000..3326a6de --- /dev/null +++ b/.agents/skills/bricks-local-cloud-dev/SKILL.md @@ -0,0 +1,133 @@ +--- +name: bricks-local-cloud-dev +description: Start or explain the Bricks local manual development environment for UI/style/interaction work using a local Node backend, local Flutter Web frontend, cloud Turso data from .env.local, and Quick Login (Test) via BRICKS_TEST_TOKEN. Use when the user wants to edit code locally and inspect the real app with realistic cloud data, not when creating automated browser evidence. +--- + +# Bricks Local Cloud Development + +Use this skill when the user wants to manually develop Bricks locally and inspect +styles, layout, interaction, navigation, or chat behavior against realistic cloud +data. + +This is a manual development workflow, not the evidence harness. For automated +browser proof, use `evidence-driven-browser-test-env` instead. + +## Safety Rules + +- Never print, copy, commit, or summarize secret values from `.env.local`. +- It is OK to reference uncommitted local paths such as `.env.local`. +- When connecting a local backend to cloud Turso, force `AUTO_MIGRATE=false`. +- Do not write provider keys into cloud Turso for ordinary UI development. +- Prefer local env provider config, if needed: `LOCAL_LLM_CONFIG_ENABLED=true` + with provider keys kept only in `.env.local`. +- Tell the user that rows written through Quick Login belong to `FIXTURE_USER_ID`, + not necessarily their normal production login user. + +## Expected Local Files + +Read `.env.local` only as an environment source. Do not display its contents. + +Required variables: + +- `TURSO_DATABASE_URL` +- `TURSO_AUTH_TOKEN` +- `JWT_SECRET` +- `FIXTURE_USER_ID` +- `BRICKS_TEST_TOKEN` + +Recommended variables: + +- `PORT=3010` +- `BRICKS_API_BASE_URL=http://127.0.0.1:3010` +- `AUTO_MIGRATE=false` +- `BRICKS_LOCAL_DEV=true` +- `LOCAL_LLM_CONFIG_ENABLED=true` when testing real model replies from local env + +## Startup Workflow + +1. Check the branch and worktree first: + + ```bash + git status --short --branch + ``` + +2. Create or switch to the user's requested development branch. If they did not + name one, create a short local branch from current `main`, for example: + + ```bash + git switch -c dev/local-cloud-db-ui + ``` + +3. Start the backend from `apps/node_backend`: + + ```bash + set -a + source ../../.env.local + set +a + PORT=3010 AUTO_MIGRATE=false npm run dev + ``` + + If the sandbox blocks `tsx` IPC or local server binding, rerun the same + command with required escalation. + +4. Start Flutter Web from `apps/mobile_chat_app`: + + ```bash + set -a + source ../../.env.local + set +a + PATH=/Users/admin/.local/tools/flutter/bin:$PATH flutter run \ + -d web-server \ + --web-hostname 127.0.0.1 \ + --web-port 8082 \ + --dart-define=BRICKS_API_BASE_URL=http://127.0.0.1:3010 \ + --dart-define=BRICKS_TEST_TOKEN="$BRICKS_TEST_TOKEN" + ``` + +5. Give the user the frontend URL: + + ```text + http://127.0.0.1:8082 + ``` + +## Verification Before Hand-Off + +Before telling the user it is ready, verify: + +- backend health: + + ```bash + curl -sS http://127.0.0.1:3010/api/health + ``` + +- Flutter Web printed a serving URL for `127.0.0.1:8082`. +- The login page should show `Quick Login (Test)` because + `BRICKS_TEST_TOKEN` was injected as a Dart define. + +If checking `/api/auth/me`, do not print the token. Use a command or script that +only reports HTTP status and whether a user object exists. + +## Response Pattern + +When the environment is running, answer with: + +- current branch name +- frontend URL +- backend URL +- whether cloud Turso is in use +- `AUTO_MIGRATE=false` confirmation +- Quick Login availability +- any caveat about fixture user identity or expected write scope + +Keep the response short. The user mainly needs the URL and safety state. + +## Troubleshooting + +- Backend `listen EPERM` for `tsx` IPC: rerun with escalation. +- `Quick Login (Test)` missing: confirm `BRICKS_TEST_TOKEN` was passed via + `--dart-define`, then hot restart or restart Flutter Web. +- API calls hit production instead of local backend: confirm + `BRICKS_API_BASE_URL=http://127.0.0.1:3010` was passed as a Dart define. +- DB writes are not visible in the user's normal production account: check + `FIXTURE_USER_ID`; Quick Login writes as that fixture user. +- Migration concerns: stop and confirm `AUTO_MIGRATE=false` before continuing. diff --git a/apps/mobile_chat_app/lib/features/chat/chat_navigation_page.dart b/apps/mobile_chat_app/lib/features/chat/chat_navigation_page.dart index ef2a6b31..928bcfd9 100644 --- a/apps/mobile_chat_app/lib/features/chat/chat_navigation_page.dart +++ b/apps/mobile_chat_app/lib/features/chat/chat_navigation_page.dart @@ -419,17 +419,13 @@ class _ChatNavigationPageState extends State ) else ...resources.map((resource) { - final notes = resource.notes?.trim(); return ListTile( - leading: Icon(_iconForResourceType(resource.type)), - title: Text(resource.title), - subtitle: notes != null && notes.isNotEmpty - ? Text( - notes, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ) - : null, + title: Text( + resource.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: _ResourceTypeMeta(resource: resource), trailing: const Icon(Icons.chevron_right), onTap: () { if (widget.onResourceSelected != null) { @@ -598,6 +594,40 @@ class _NodeDetailPage extends StatelessWidget { } } +class _ResourceTypeMeta extends StatelessWidget { + const _ResourceTypeMeta({required this.resource}); + + final ChatResourceItem resource; + + @override + Widget build(BuildContext context) { + final textStyle = Theme.of(context).textTheme.bodySmall; + + return Padding( + padding: const EdgeInsets.only(top: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _iconForResourceType(resource.type), + size: 14, + color: textStyle?.color, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + _labelForResourceType(resource.type), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyle, + ), + ), + ], + ), + ); + } +} + /// Preview page for a resource item. /// /// When the resource is a todo-list or note and the corresponding API service diff --git a/apps/mobile_chat_app/lib/features/chat/chat_screen.dart b/apps/mobile_chat_app/lib/features/chat/chat_screen.dart index 0f9b34d7..29fb61e3 100644 --- a/apps/mobile_chat_app/lib/features/chat/chat_screen.dart +++ b/apps/mobile_chat_app/lib/features/chat/chat_screen.dart @@ -3285,15 +3285,19 @@ class _ChatScreenState extends State { }, child: SizedBox( width: 12, - child: Center( - child: SizedBox( - width: 1, - child: DecoratedBox( - decoration: BoxDecoration( - color: theme.dividerColor, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: 1, + child: DecoratedBox( + decoration: BoxDecoration( + color: theme.dividerColor, + ), ), ), - ), + ], ), ), ), diff --git a/apps/mobile_chat_app/test/chat_navigation_page_test.dart b/apps/mobile_chat_app/test/chat_navigation_page_test.dart index 105d95b2..3779b2ce 100644 --- a/apps/mobile_chat_app/test/chat_navigation_page_test.dart +++ b/apps/mobile_chat_app/test/chat_navigation_page_test.dart @@ -271,7 +271,8 @@ void main() { ChatResourceItem( id: 'highlight_1', type: ChatResourceType.textHighlight, - title: 'Important highlighted text', + title: + 'Important highlighted text with enough detail to wrap beyond two visual lines', updatedAt: DateTime.utc(2026, 5, 19, 10), notes: 'Highlighted text', ), @@ -291,18 +292,52 @@ void main() { expect(find.text('My Todo List'), findsOneWidget); expect(find.text('Asset Table'), findsOneWidget); - expect(find.text('Important highlighted text'), findsOneWidget); + expect( + find.text( + 'Important highlighted text with enough detail to wrap beyond two visual lines', + ), + findsOneWidget, + ); expect(find.text('Research Note'), findsOneWidget); + expect(find.text('Todo List'), findsOneWidget); + expect(find.text('Table'), findsOneWidget); + expect(find.text('Text Highlight'), findsOneWidget); + expect(find.text('Note'), findsOneWidget); expect(find.byIcon(Icons.checklist_outlined), findsOneWidget); expect(find.byIcon(Icons.table_chart_outlined), findsOneWidget); expect(find.byIcon(Icons.description_outlined), findsOneWidget); expect(find.byIcon(Icons.format_color_text_outlined), findsOneWidget); + final highlightTile = tester.widget( + find.widgetWithText( + ListTile, + 'Important highlighted text with enough detail to wrap beyond two visual lines', + ), + ); + expect(highlightTile.leading, isNull); + + final highlightTitle = tester.widget( + find.descendant( + of: find.widgetWithText( + ListTile, + 'Important highlighted text with enough detail to wrap beyond two visual lines', + ), + matching: find.text( + 'Important highlighted text with enough detail to wrap beyond two visual lines', + ), + ), + ); + expect(highlightTitle.maxLines, 2); + expect(highlightTitle.overflow, TextOverflow.ellipsis); + final noteTop = tester.getTopLeft( find.widgetWithText(ListTile, 'Research Note'), ); final highlightTop = tester.getTopLeft( - find.widgetWithText(ListTile, 'Important highlighted text'), + find.widgetWithText( + ListTile, + 'Important highlighted text with enough detail to wrap beyond two visual lines', + ), ); final tableTop = tester.getTopLeft( find.widgetWithText(ListTile, 'Asset Table'), diff --git a/docs/code_maps/feature_map.yaml b/docs/code_maps/feature_map.yaml index 4fdb1def..a7f95367 100644 --- a/docs/code_maps/feature_map.yaml +++ b/docs/code_maps/feature_map.yaml @@ -42,6 +42,7 @@ products: - 发送一条消息后可以看到消息列表更新。 - 发送消息后输入框应保持可输入并恢复焦点,流式回复期间仍可继续编辑下一条草稿。 - 宽屏内联 Navigation 打开时,拖动右侧分隔手柄应能在最小/最大宽度范围内调整侧边栏宽度。 + - 宽屏内联 Navigation 打开时,Navigation 与 chat 区域之间应显示一条可拖拽的垂直分界线。 - 将路由切到 plugin/OpenClaw 节点后发送消息,在插件离线时应呈现“已发送但未完成(未读)”。 - 启动并配置 openclaw 插件后再次发送,消息应完成并出现 assistant 回复。 - 当用户拥有多个 OpenClaw 节点时,输入框左侧路由菜单应仅列出真实节点;菜单主标题显示节点名称,副标题显示 `OpenClaw`。 @@ -193,6 +194,7 @@ products: - 页面可正常渲染且无崩溃。 - Chat navigation 的 Resources tab 将 todo lists、asset tables、notes、text highlights 作为原子资源按更新时间倒序显示。 - Resources tab 顶部 type filter 可按资源类型过滤列表。 + - Resources tab item 标题最多显示两行并省略溢出,第二行用小 icon + 类型文本显示资源类型。 - feature_id: conversational_todos name: 对话式待办列表与待办事项 diff --git a/docs/code_maps/logic_map.yaml b/docs/code_maps/logic_map.yaml index f272ed97..dd3300cd 100644 --- a/docs/code_maps/logic_map.yaml +++ b/docs/code_maps/logic_map.yaml @@ -147,6 +147,7 @@ index: - docs/tasks/done/2026-05-19-14-27-CST-tool-call-thinking-group.md - docs/tasks/done/2026-05-19-17-38-CST-subsection-rename-menu.md - docs/tasks/done/2026-05-21-10-41-CST-channel-new-ui-harness.md + - docs/plans/2026-06-23-16-24-CST-desktop-sidebar-divider-visibility.md - docs/testing/local-chat-fixture-db.md test_index: - apps/mobile_chat_app/test/chat_arbitration_engine_test.dart @@ -428,6 +429,7 @@ index: - docs/faq/common-issues.md - docs/architecture.md - docs/tasks/done/2026-05-19-17-06-CST-navigation-resources-highlights.md + - docs/plans/2026-06-23-15-51-CST-resource-item-meta-icon-layout.md test_index: - apps/mobile_chat_app/test/app_test.dart - apps/mobile_chat_app/test/chat_navigation_page_test.dart @@ -447,6 +449,7 @@ index: - 页面间返回路径断裂造成死路由。 - Resources tab 若按类型分组拼接而不是统一按 updatedAt 排序,会破坏跨资源时间线浏览。 - 新资源类型必须提供稳定 updatedAt,否则倒序排序会不确定。 + - Resources item 若恢复 large leading icon,会重新造成左/右结构而弱化资源标题和类型信息的层级。 - Agent tool 成功修改资源后,应通过 typed invalidation 刷新 Resources 列表,否则用户需要刷新页面才看到新资源。 - feature_id: backend_auth diff --git a/docs/plans/2026-06-23-15-17-CST-bricks-local-cloud-dev-skill.md b/docs/plans/2026-06-23-15-17-CST-bricks-local-cloud-dev-skill.md new file mode 100644 index 00000000..355b2d09 --- /dev/null +++ b/docs/plans/2026-06-23-15-17-CST-bricks-local-cloud-dev-skill.md @@ -0,0 +1,38 @@ +# Bricks Local Cloud Dev Skill + +## Background + +Manual UI, style, and interaction work often needs the real Bricks app running +locally against realistic cloud data. The existing evidence-oriented browser +skill covers proof collection, but this workflow needs a separate agent skill +focused on local development startup and safe hand-off to a human tester. + +## Goals + +- Add a dedicated skill for local backend + cloud Turso + local Flutter Web. +- Document Quick Login via `BRICKS_TEST_TOKEN` without storing or exposing + secrets. +- Keep cloud database safeguards explicit, especially `AUTO_MIGRATE=false`. + +## Implementation Plan + +1. Create `.agents/skills/bricks-local-cloud-dev/SKILL.md`. +2. Include startup commands for the local Node backend and Flutter Web frontend. +3. Reference `.env.local` as the secret source without printing values. +4. Include verification and troubleshooting notes for Quick Login and cloud DB + safety. + +## Acceptance Criteria + +- The skill is discoverable from its frontmatter description when the user asks + to start a local development environment against cloud data. +- The skill does not contain any secret values or remote auth tokens. +- The skill clearly distinguishes manual development from automated evidence + testing. +- The skill instructs agents to force `AUTO_MIGRATE=false` when using cloud + Turso. + +## Validation Commands + +- `sed -n '1,220p' .agents/skills/bricks-local-cloud-dev/SKILL.md` +- `rg -n "BEGIN PRIVATE KEY|sk-" .agents/skills/bricks-local-cloud-dev docs/plans/2026-06-23-15-17-CST-bricks-local-cloud-dev-skill.md` diff --git a/docs/plans/2026-06-23-15-51-CST-resource-item-meta-icon-layout.md b/docs/plans/2026-06-23-15-51-CST-resource-item-meta-icon-layout.md new file mode 100644 index 00000000..612ece5c --- /dev/null +++ b/docs/plans/2026-06-23-15-51-CST-resource-item-meta-icon-layout.md @@ -0,0 +1,41 @@ +# Resource Item Meta Icon Layout + +## Background + +The Resources tab currently renders each resource item as a left/right row with +a large leading icon and text content on the right. The requested layout should +make each item text-first, with the resource type shown as the second line and a +small type icon placed directly before that metadata text. + +## Goals + +- Remove the large leading icon from resource list items. +- Limit resource titles to two lines with an ellipsis when overflowing. +- Render the resource type as an inline metadata row under the title. +- Place a small resource-type icon immediately before the metadata text. +- Preserve resource ordering, filtering, and tap behavior. + +## Implementation Plan + +1. Update the Resources tab item builder in `ChatNavigationPage`. +2. Replace `ListTile.leading` with a compact subtitle row containing the icon + and resource type label. +3. Limit title text to two lines with `TextOverflow.ellipsis`. +4. Add a widget test that verifies resource icons are rendered inside the tile + subtitle area rather than as leading icons. + +## Acceptance Criteria + +- A resource item title starts at the normal tile text inset, not to the right + of a large leading icon. +- The second line shows a small icon followed by the resource type label, such + as `Text Highlight`. +- Long resource titles are capped at two lines and overflow with an ellipsis. +- Existing Resources tab sorting, filtering, and preview navigation continue to + work. + +## Validation Commands + +- `./tools/init_dev_env.sh` +- `cd apps/mobile_chat_app && flutter test test/chat_navigation_page_test.dart` +- `cd apps/mobile_chat_app && flutter analyze` diff --git a/docs/plans/2026-06-23-16-24-CST-desktop-sidebar-divider-visibility.md b/docs/plans/2026-06-23-16-24-CST-desktop-sidebar-divider-visibility.md new file mode 100644 index 00000000..d8adc54d --- /dev/null +++ b/docs/plans/2026-06-23-16-24-CST-desktop-sidebar-divider-visibility.md @@ -0,0 +1,35 @@ +# Desktop Sidebar Divider Visibility + +## Background + +The desktop chat navigation sidebar already has a 12px resize drag handle, but +the visible 1px divider inside that handle does not receive an explicit height. +As a result, the boundary between navigation and chat can be visually missing +even though drag resizing works. + +## Goals + +- Keep the existing desktop sidebar resize behavior. +- Make the navigation/chat boundary visible whenever the desktop sidebar is + open. +- Avoid introducing new color tokens or hard-coded colors. + +## Implementation Plan + +1. Update the desktop sidebar resize handle in `ChatScreen`. +2. Preserve the 12px opaque drag target and resize cursor. +3. Make the inner 1px divider fill the available height using the existing + theme divider color. +4. Run focused formatting and Flutter analysis. + +## Acceptance Criteria + +- On desktop, opening the navigation sidebar shows a full-height divider between + navigation and chat. +- Dragging the divider still changes sidebar width within existing bounds. +- The change does not alter mobile drawer behavior. + +## Validation Commands + +- `dart format apps/mobile_chat_app/lib/features/chat/chat_screen.dart` +- `cd apps/mobile_chat_app && flutter analyze` From 5b19c0b81027d4374a1faa504e412d4d157ff5e2 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 23 Jun 2026 20:26:27 +0800 Subject: [PATCH 2/3] Replace channel names with chat channels registry --- .../chat/chat_history_api_service.dart | 63 +++-- .../lib/features/chat/chat_screen.dart | 235 +++++++----------- .../test/chat_history_api_service_test.dart | 54 +++- .../migrations/025_create_chat_channels.sql | 63 +++++ apps/node_backend/src/routes/chat.test.ts | 98 +++++--- apps/node_backend/src/routes/chat.ts | 83 +++++-- .../src/scripts/exportChatFixtureDb.ts | 4 +- .../chatAsyncTransportService.test.ts | 25 +- .../src/services/chatAsyncTransportService.ts | 24 +- .../src/services/chatChannelNameService.ts | 170 ------------- .../src/services/chatChannelService.ts | 201 +++++++++++++++ .../services/localAgentLoopService.test.ts | 31 ++- .../src/services/localAgentLoopService.ts | 126 ++++++---- docs/code_maps/feature_map.yaml | 10 +- docs/code_maps/logic_map.yaml | 11 +- ...CST-chat-channels-replace-channel-names.md | 50 ++++ 16 files changed, 742 insertions(+), 506 deletions(-) create mode 100644 apps/node_backend/src/db/migrations/025_create_chat_channels.sql delete mode 100644 apps/node_backend/src/services/chatChannelNameService.ts create mode 100644 apps/node_backend/src/services/chatChannelService.ts create mode 100644 docs/tasks/done/2026-06-23-17-45-CST-chat-channels-replace-channel-names.md diff --git a/apps/mobile_chat_app/lib/features/chat/chat_history_api_service.dart b/apps/mobile_chat_app/lib/features/chat/chat_history_api_service.dart index 19426b5c..e49d5ac8 100644 --- a/apps/mobile_chat_app/lib/features/chat/chat_history_api_service.dart +++ b/apps/mobile_chat_app/lib/features/chat/chat_history_api_service.dart @@ -77,15 +77,17 @@ class ChatForkResult { final String forkedSessionId; } -class ChatChannelNameSetting { - const ChatChannelNameSetting({ +class ChatChannelSetting { + const ChatChannelSetting({ required this.channelId, required this.displayName, + required this.scopeType, this.threadId, }); final String channelId; final String displayName; + final ChatScopeType scopeType; final String? threadId; } @@ -145,7 +147,8 @@ class ChatHistoryApiService { Uri get _batchMessagesUri => Uri.parse('$_base/api/chat/messages/batch'); Uri get _scopesUri => Uri.parse('$_base/api/chat/scopes'); Uri get _scopeSettingsUri => Uri.parse('$_base/api/chat/scope-settings'); - Uri get _channelNamesUri => Uri.parse('$_base/api/chat/channel-names'); + Uri get _channelsUri => Uri.parse('$_base/api/chat/channels'); + Uri get _archiveChannelUri => Uri.parse('$_base/api/chat/channels/archive'); Uri get _forkUri => Uri.parse('$_base/api/chat/fork'); ChatTaskState? _parseTaskState(Object? value) { @@ -216,26 +219,31 @@ class ChatHistoryApiService { }).toList(growable: false); } - Future> loadChannelNames() async { - final response = await _apiClient.get(_channelNamesUri); + Future> loadChannels() async { + final response = await _apiClient.get(_channelsUri); if (response.statusCode != 200) { throw Exception( - 'Failed to load chat channel names (${response.statusCode})', + 'Failed to load chat channels (${response.statusCode})', ); } final raw = jsonDecode(response.body); if (raw is! Map) return const []; final map = Map.from(raw); - return ((map['channelNames'] as List?) ?? const []) + return ((map['channels'] as List?) ?? const []) .whereType() .map((item) => Map.from(item)) - .map( - (item) => ChatChannelNameSetting( + .map((item) { + final scopeType = chatScopeTypeFromApi(item['scopeType'] as String?); + if (scopeType == null) { + throw const FormatException('Invalid scopeType'); + } + return ChatChannelSetting( channelId: (item['channelId'] as String?) ?? '', displayName: (item['displayName'] as String?) ?? '', + scopeType: scopeType, threadId: item['threadId'] as String?, - ), - ) + ); + }) .where( (item) => item.channelId.trim().isNotEmpty && @@ -460,25 +468,48 @@ class ChatHistoryApiService { } } - Future saveChannelName({ + Future saveChannel({ required String channelId, - String? displayName, + required String displayName, String? threadId, }) async { final response = await _apiClient.put( - _channelNamesUri, + _channelsUri, + headers: { + 'Content-Type': 'application/json', + }, + body: jsonEncode({ + 'channelId': channelId, + if (threadId != null) 'threadId': threadId, + 'displayName': displayName.trim(), + }), + ); + if (response.statusCode != 200) { + throw Exception( + 'Failed to save chat channel (${response.statusCode})', + ); + } + } + + Future archiveChannel({ + required String channelId, + required String displayName, + String? threadId, + }) async { + final response = await _apiClient.post( + _archiveChannelUri, headers: { 'Content-Type': 'application/json', }, body: jsonEncode({ 'channelId': channelId, if (threadId != null) 'threadId': threadId, - 'displayName': displayName?.trim(), + 'displayName': displayName.trim(), }), ); if (response.statusCode != 200) { throw Exception( - 'Failed to save chat channel name (${response.statusCode})', + 'Failed to archive chat channel (${response.statusCode})', ); } } diff --git a/apps/mobile_chat_app/lib/features/chat/chat_screen.dart b/apps/mobile_chat_app/lib/features/chat/chat_screen.dart index 29fb61e3..fb5def9c 100644 --- a/apps/mobile_chat_app/lib/features/chat/chat_screen.dart +++ b/apps/mobile_chat_app/lib/features/chat/chat_screen.dart @@ -168,7 +168,7 @@ class _ChatScreenState extends State { final mergedDefinitions = _mergeWithBuiltInAgents(customDefinitions); List persistedScopes = const []; List scopeSettings = const []; - List channelNames = const []; + List channels = const []; List platformNodes = const []; Map> openClawAgentsByNodeId = const {}; List todoLists = const []; @@ -191,10 +191,10 @@ class _ChatScreenState extends State { ); } try { - channelNames = await _chatHistoryApiService.loadChannelNames(); + channels = await _chatHistoryApiService.loadChannels(); } catch (e) { debugPrint( - 'loadChannelNames failed, continuing without channel name hydration: $e', + 'loadChannels failed, continuing without channel hydration: $e', ); } try { @@ -246,17 +246,13 @@ class _ChatScreenState extends State { ); if (!mounted) return; _syncParticipants(mergedDefinitions); - final restoredChannels = _hydrateChannelsFromScopes(persistedScopes); - final restoredNamedChannels = _applyPersistedChannelNames( - channels: restoredChannels, - channelNames: channelNames, - ); - final restoredSubSections = _hydrateSubSectionsFromScopes( - scopes: persistedScopes, - channelNames: channelNames, - ); + final restoredChannels = _hydrateChannelsFromRegistry(channels); + final restoredSubSections = _hydrateSubSectionsFromRegistry(channels); final restoredLastSubSectionByChannel = - _hydrateLastActiveSubSectionByChannel(persistedScopes); + _hydrateLastActiveSubSectionByChannel( + persistedScopes, + restoredSubSections, + ); final restoredChannelLastMessageAt = _hydrateChannelLastMessageAt(persistedScopes); final restoredChannelRouters = _hydrateChannelRouters(scopeSettings); @@ -289,7 +285,7 @@ class _ChatScreenState extends State { _authToken = authToken; _channels ..clear() - ..addAll(restoredNamedChannels); + ..addAll(restoredChannels); _channelLastMessageAt ..clear() ..addAll(restoredChannelLastMessageAt); @@ -657,20 +653,8 @@ class _ChatScreenState extends State { return '$prefix-${now.year}-${two(now.month)}-${two(now.day)}-${two(now.hour)}-${two(now.minute)}-${two(now.second)}-${three(now.millisecond)}'; } - String _fallbackScopeName(String id, {required String prefix}) { - final parsedEpoch = int.tryParse( - id.replaceFirst(RegExp(r'^[a-zA-Z_-]+-'), ''), - ); - if (parsedEpoch != null && parsedEpoch > 0) { - final dt = DateTime.fromMillisecondsSinceEpoch(parsedEpoch); - String two(int value) => value.toString().padLeft(2, '0'); - return '$prefix-${dt.year}-${two(dt.month)}-${two(dt.day)}-${two(dt.hour)}-${two(dt.minute)}'; - } - return id; - } - - List _hydrateChannelsFromScopes( - List scopes, + List _hydrateChannelsFromRegistry( + List channels, ) { final channelsById = { 'default': const ChatChannel( @@ -679,13 +663,14 @@ class _ChatScreenState extends State { isDefault: true, ), }; - for (final scope in scopes) { - if (scope.channelId == 'default') continue; + for (final channel in channels) { + if (channel.scopeType != ChatScopeType.channel) continue; + if (channel.channelId == 'default') continue; channelsById.putIfAbsent( - scope.channelId, + channel.channelId, () => ChatChannel( - id: scope.channelId, - name: _fallbackScopeName(scope.channelId, prefix: 'channel'), + id: channel.channelId, + name: channel.displayName, isDefault: false, ), ); @@ -713,63 +698,52 @@ class _ChatScreenState extends State { return byChannel; } - Map> _hydrateSubSectionsFromScopes({ - required List scopes, - required List channelNames, - }) { - final threadNamesByScope = { - for (final item in channelNames) - if (item.threadId != null && - item.threadId!.trim().isNotEmpty && - item.threadId != 'main') - _subSectionKey(item.channelId, item.threadId!): item.displayName, - }; + Map> _hydrateSubSectionsFromRegistry( + List channels, + ) { final subSections = >{ 'default': [], }; - for (final scope in scopes) { + for (final channel in channels) { + final threadId = channel.threadId; + if (channel.scopeType != ChatScopeType.thread || + threadId == null || + threadId.trim().isEmpty || + threadId == 'main') { + continue; + } final channelSections = subSections.putIfAbsent( - scope.channelId, + channel.channelId, () => [], ); - if (scope.threadId == 'main' || - channelSections.any((item) => item.id == scope.threadId)) { + if (channelSections.any((item) => item.id == threadId)) { continue; } channelSections.add( ChatSubSection( - id: scope.threadId, - parentChannelId: scope.channelId, - name: threadNamesByScope[_subSectionKey( - scope.channelId, - scope.threadId, - )] ?? - _fallbackScopeName(scope.threadId, prefix: 'sub'), - createdAt: scope.lastActivityAt ?? DateTime.now(), + id: threadId, + parentChannelId: channel.channelId, + name: channel.displayName, + createdAt: DateTime.now(), ), ); } return subSections; } - List _applyPersistedChannelNames({ - required List channels, - required List channelNames, - }) { - if (channelNames.isEmpty) return channels; - final namesById = { - for (final item in channelNames) - if (item.threadId == null || item.threadId!.trim().isEmpty) - item.channelId: item.displayName, - }; - return applyChannelDisplayNames(channels, namesById); - } - Map _hydrateLastActiveSubSectionByChannel( List scopes, + Map> subSectionsByChannel, ) { final byChannel = {}; for (final scope in scopes) { + if (scope.threadId != 'main') { + final visibleSections = subSectionsByChannel[scope.channelId]; + final isVisible = + visibleSections?.any((section) => section.id == scope.threadId) ?? + false; + if (!isVisible) continue; + } final current = byChannel[scope.channelId]; final currentAt = current?.lastActivityAt; final nextAt = scope.lastActivityAt; @@ -894,7 +868,7 @@ class _ChatScreenState extends State { _configureActiveScopeSync(); unawaited( _chatHistoryApiService - .saveChannelName( + .saveChannel( channelId: id, displayName: name, ) @@ -946,7 +920,7 @@ class _ChatScreenState extends State { }); unawaited( _chatHistoryApiService - .saveChannelName( + .saveChannel( channelId: channelId, displayName: name, ) @@ -989,9 +963,9 @@ class _ChatScreenState extends State { } unawaited( _chatHistoryApiService - .saveChannelName( + .archiveChannel( channelId: channelId, - displayName: null, + displayName: channel.name, ) .catchError((Object error, StackTrace stackTrace) { debugPrint('Failed to archive channel "$channelId": $error'); @@ -1341,6 +1315,17 @@ class _ChatScreenState extends State { _lastSyncedSeq = 0; }); _configureActiveScopeSync(); + unawaited( + _chatHistoryApiService + .saveChannel( + channelId: _activeChannelId, + threadId: section.id, + displayName: section.name, + ) + .catchError((Object error, StackTrace stackTrace) { + debugPrint('Failed to save subsection "${section.id}": $error'); + }), + ); } Future _handleBranch(ChatMessage message) async { @@ -2129,7 +2114,7 @@ class _ChatScreenState extends State { void _consumeChatInvalidations(ChatHistorySnapshot snapshot) { var refreshScopes = false; - var refreshChannelNames = false; + var refreshChannels = false; var refreshScopeSettings = false; var refreshResources = false; @@ -2144,7 +2129,7 @@ class _ChatScreenState extends State { case ChatInvalidationKind.chatScopes: refreshScopes = true; case ChatInvalidationKind.chatChannelNames: - refreshChannelNames = true; + refreshChannels = true; case ChatInvalidationKind.chatScopeSettings: refreshScopeSettings = true; case ChatInvalidationKind.resourcesTodoLists: @@ -2159,11 +2144,11 @@ class _ChatScreenState extends State { } } - if (refreshScopes || refreshChannelNames || refreshScopeSettings) { + if (refreshScopes || refreshChannels || refreshScopeSettings) { unawaited( _refreshScopeTopologyParts( refreshScopes: refreshScopes, - refreshChannelNames: refreshChannelNames, + refreshChannels: refreshChannels, refreshScopeSettings: refreshScopeSettings, ), ); @@ -2196,66 +2181,26 @@ class _ChatScreenState extends State { ), ]; - List _currentChannelNameSettings() => [ + List _currentChannelSettings() => [ for (final channel in _channels) - ChatChannelNameSetting( + ChatChannelSetting( channelId: channel.id, displayName: channel.name, + scopeType: ChatScopeType.channel, ), for (final sections in _channelSubSections.values) for (final section in sections) - ChatChannelNameSetting( + ChatChannelSetting( channelId: section.parentChannelId, threadId: section.id, displayName: section.name, + scopeType: ChatScopeType.thread, ), ]; - void _applyChannelNamesToCurrentTopology( - List channelNames, - ) { - final channelNamesById = { - for (final item in channelNames) - if (item.threadId == null || item.threadId == 'main') - item.channelId: item.displayName, - }; - final threadNamesByKey = { - for (final item in channelNames) - if (item.threadId != null && - item.threadId!.trim().isNotEmpty && - item.threadId != 'main') - _subSectionKey(item.channelId, item.threadId!): item.displayName, - }; - - final renamedChannels = [ - for (final channel in _channels) - ChatChannel( - id: channel.id, - name: channelNamesById[channel.id] ?? channel.name, - isDefault: channel.isDefault, - ), - ]; - _channels - ..clear() - ..addAll(renamedChannels); - _channelSubSections.updateAll( - (channelId, sections) => [ - for (final section in sections) - ChatSubSection( - id: section.id, - parentChannelId: section.parentChannelId, - name: threadNamesByKey[ - _subSectionKey(section.parentChannelId, section.id)] ?? - section.name, - createdAt: section.createdAt, - ), - ], - ); - } - Future _refreshScopeTopologyParts({ required bool refreshScopes, - required bool refreshChannelNames, + required bool refreshChannels, required bool refreshScopeSettings, }) async { if (_refreshingScopeTopology) return; @@ -2263,7 +2208,7 @@ class _ChatScreenState extends State { try { final results = await Future.wait([ if (refreshScopes) _chatHistoryApiService.loadScopes(), - if (refreshChannelNames) _chatHistoryApiService.loadChannelNames(), + if (refreshChannels) _chatHistoryApiService.loadChannels(), if (refreshScopeSettings) _chatHistoryApiService.loadScopeSettings(), ]); if (!mounted) return; @@ -2271,30 +2216,23 @@ class _ChatScreenState extends State { final scopes = refreshScopes ? results[index++] as List : _currentPersistedScopes(); - final channelNames = refreshChannelNames - ? results[index++] as List - : _currentChannelNameSettings(); + final channels = refreshChannels + ? results[index++] as List + : _currentChannelSettings(); final settings = refreshScopeSettings ? results[index++] as List : const []; - final restoredChannels = _hydrateChannelsFromScopes(scopes); - final restoredNamedChannels = _applyPersistedChannelNames( - channels: restoredChannels, - channelNames: channelNames, - ); + final restoredChannels = _hydrateChannelsFromRegistry(channels); final restoredChannelLastMessageAt = _hydrateChannelLastMessageAt(scopes); - final restoredSubSections = _hydrateSubSectionsFromScopes( - scopes: scopes, - channelNames: channelNames, - ); + final restoredSubSections = _hydrateSubSectionsFromRegistry(channels); final restoredLastSubSectionByChannel = - _hydrateLastActiveSubSectionByChannel(scopes); + _hydrateLastActiveSubSectionByChannel(scopes, restoredSubSections); setState(() { - if (refreshScopes) { + if (refreshScopes || refreshChannels) { _channels ..clear() - ..addAll(restoredNamedChannels); + ..addAll(restoredChannels); _channelLastMessageAt ..clear() ..addAll(restoredChannelLastMessageAt); @@ -2304,8 +2242,6 @@ class _ChatScreenState extends State { _lastActiveSubSectionByChannel ..clear() ..addAll(restoredLastSubSectionByChannel); - } else if (refreshChannelNames) { - _applyChannelNamesToCurrentTopology(channelNames); } if (refreshScopeSettings) { _channelRouters @@ -2358,6 +2294,17 @@ class _ChatScreenState extends State { _lastSyncedSeq = 0; }); _configureActiveScopeSync(); + unawaited( + _chatHistoryApiService + .saveChannel( + channelId: _activeChannelId, + threadId: section.id, + displayName: section.name, + ) + .catchError((Object error, StackTrace stackTrace) { + debugPrint('Failed to save subsection "${section.id}": $error'); + }), + ); } void _renameActiveSubSection() { @@ -2401,7 +2348,7 @@ class _ChatScreenState extends State { }); unawaited( _chatHistoryApiService - .saveChannelName( + .saveChannel( channelId: channelId, threadId: sectionId, displayName: name, @@ -2439,10 +2386,10 @@ class _ChatScreenState extends State { unawaited(_loadMessagesForActiveScope()); unawaited( _chatHistoryApiService - .saveChannelName( + .archiveChannel( channelId: channelId, threadId: sectionId, - displayName: null, + displayName: sectionName, ) .catchError((Object error, StackTrace stackTrace) { debugPrint('Failed to archive subsection "$sectionId": $error'); diff --git a/apps/mobile_chat_app/test/chat_history_api_service_test.dart b/apps/mobile_chat_app/test/chat_history_api_service_test.dart index d03e3b25..7c3687e8 100644 --- a/apps/mobile_chat_app/test/chat_history_api_service_test.dart +++ b/apps/mobile_chat_app/test/chat_history_api_service_test.dart @@ -435,20 +435,23 @@ void main() { expect(settings.single.resolvedTargetPluginId, 'plugin-local-main'); }); - test('loads and saves channel name mappings', () async { + test('loads, saves, and archives chat channels', () async { + var sawSave = false; final client = MockClient((request) async { if (request.method == 'GET') { - expect(request.url.path.endsWith('/chat/channel-names'), isTrue); + expect(request.url.path.endsWith('/chat/channels'), isTrue); return http.Response( jsonEncode({ - 'channelNames': [ + 'channels': [ { 'channelId': 'channel-1', + 'scopeType': 'channel', 'displayName': 'renamed-channel', }, { 'channelId': 'channel-1', 'threadId': 'sub-1', + 'scopeType': 'thread', 'displayName': 'renamed-subsection', }, ], @@ -457,11 +460,31 @@ void main() { ); } - expect(request.method, equals('PUT')); + if (request.method == 'PUT') { + sawSave = true; + expect(request.url.path.endsWith('/chat/channels'), isTrue); + final decoded = jsonDecode(request.body) as Map; + expect(decoded['channelId'], equals('channel-1')); + expect(decoded['threadId'], equals('sub-1')); + expect(decoded['displayName'], equals('latest-channel-name')); + return http.Response( + jsonEncode({ + 'setting': { + 'channelId': 'channel-1', + 'displayName': 'latest-channel-name', + }, + }), + 200, + ); + } + + expect(request.method, equals('POST')); + expect(sawSave, isTrue); + expect(request.url.path.endsWith('/chat/channels/archive'), isTrue); final decoded = jsonDecode(request.body) as Map; expect(decoded['channelId'], equals('channel-1')); expect(decoded['threadId'], equals('sub-1')); - expect(decoded['displayName'], equals('latest-channel-name')); + expect(decoded['displayName'], equals('renamed-subsection')); return http.Response( jsonEncode({ 'setting': { @@ -474,18 +497,25 @@ void main() { }); final service = _serviceFor(client); - final channelNames = await service.loadChannelNames(); - await service.saveChannelName( + final channels = await service.loadChannels(); + await service.saveChannel( channelId: 'channel-1', threadId: 'sub-1', displayName: 'latest-channel-name', ); + await service.archiveChannel( + channelId: 'channel-1', + threadId: 'sub-1', + displayName: 'renamed-subsection', + ); - expect(channelNames, hasLength(2)); - expect(channelNames.first.channelId, equals('channel-1')); - expect(channelNames.first.displayName, equals('renamed-channel')); - expect(channelNames.last.threadId, equals('sub-1')); - expect(channelNames.last.displayName, equals('renamed-subsection')); + expect(channels, hasLength(2)); + expect(channels.first.channelId, equals('channel-1')); + expect(channels.first.scopeType, equals(ChatScopeType.channel)); + expect(channels.first.displayName, equals('renamed-channel')); + expect(channels.last.threadId, equals('sub-1')); + expect(channels.last.scopeType, equals(ChatScopeType.thread)); + expect(channels.last.displayName, equals('renamed-subsection')); }); test('listenEvents yields parsed snapshots from SSE data frames', () async { diff --git a/apps/node_backend/src/db/migrations/025_create_chat_channels.sql b/apps/node_backend/src/db/migrations/025_create_chat_channels.sql new file mode 100644 index 00000000..fea14b40 --- /dev/null +++ b/apps/node_backend/src/db/migrations/025_create_chat_channels.sql @@ -0,0 +1,63 @@ +-- Migration: Create chat_channels registry +-- Description: Replace chat_channel_names with lifecycle-aware channel/thread scope rows. +-- Version: 025 +-- Date: 2026-06-23 + +CREATE TABLE IF NOT EXISTS chat_channels ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + channel_id VARCHAR(255) NOT NULL, + thread_id VARCHAR(255) NOT NULL DEFAULT '', + scope_type VARCHAR(32) NOT NULL DEFAULT 'channel', + display_name VARCHAR(255) NOT NULL, + source VARCHAR(64) NOT NULL DEFAULT 'manual', + generated_name_attempted_at TIMESTAMP NULL, + archived_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE (user_id, channel_id, thread_id) +); + +INSERT INTO chat_channels ( + user_id, + channel_id, + thread_id, + scope_type, + display_name, + source, + generated_name_attempted_at, + created_at, + updated_at +) +SELECT + user_id, + channel_id, + COALESCE(thread_id, ''), + CASE WHEN COALESCE(thread_id, '') = '' THEN 'channel' ELSE 'thread' END, + display_name, + source, + generated_name_attempted_at, + created_at, + updated_at +FROM chat_channel_names +WHERE TRUE +ON CONFLICT (user_id, channel_id, thread_id) +DO UPDATE SET + scope_type = EXCLUDED.scope_type, + display_name = EXCLUDED.display_name, + source = EXCLUDED.source, + generated_name_attempted_at = EXCLUDED.generated_name_attempted_at, + updated_at = EXCLUDED.updated_at; + +CREATE INDEX IF NOT EXISTS idx_chat_channels_user_active + ON chat_channels(user_id, archived_at); + +CREATE INDEX IF NOT EXISTS idx_chat_channels_scope + ON chat_channels(user_id, channel_id, thread_id); + +CREATE TRIGGER update_chat_channels_updated_at + BEFORE UPDATE ON chat_channels + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +DROP TABLE IF EXISTS chat_channel_names; diff --git a/apps/node_backend/src/routes/chat.test.ts b/apps/node_backend/src/routes/chat.test.ts index 68f825ef..f0004fde 100644 --- a/apps/node_backend/src/routes/chat.test.ts +++ b/apps/node_backend/src/routes/chat.test.ts @@ -23,9 +23,9 @@ const { claimFirstMessageGeneratedNameAttemptMock, completeFirstMessageGeneratedNameMock, insertFirstMessageExactNameIfMissingMock, - listChatChannelNamesMock, - upsertChatChannelNameMock, - deleteChatChannelNameMock, + archiveChatChannelMock, + listChatChannelsMock, + upsertChatChannelMock, generateWithUserConfigMock, streamWithAgentToolsAndUserConfigMock, buildAgentToolsMock, @@ -73,15 +73,25 @@ const { claimFirstMessageGeneratedNameAttemptMock: vi.fn(async () => null), completeFirstMessageGeneratedNameMock: vi.fn(async () => null), insertFirstMessageExactNameIfMissingMock: vi.fn(async () => null), - listChatChannelNamesMock: vi.fn(async () => []), - upsertChatChannelNameMock: vi.fn(async () => ({ + archiveChatChannelMock: vi.fn(async () => ({ channelId: "channel-1", threadId: null, + scopeType: "channel", + displayName: "项目频道", + archivedAt: "2026-04-18T08:02:00.000Z", + createdAt: "2026-04-18T08:00:00.000Z", + updatedAt: "2026-04-18T08:02:00.000Z", + })), + listChatChannelsMock: vi.fn(async () => []), + upsertChatChannelMock: vi.fn(async () => ({ + channelId: "channel-1", + threadId: null, + scopeType: "channel", displayName: "项目频道", + archivedAt: null, createdAt: "2026-04-18T08:00:00.000Z", updatedAt: "2026-04-18T08:00:00.000Z", })), - deleteChatChannelNameMock: vi.fn(async () => ({ deleted: true })), generateWithUserConfigMock: vi.fn(async () => ({ provider: "anthropic", model: "claude-sonnet-4-5", @@ -149,13 +159,13 @@ vi.mock("../services/platformNodeService.js", () => ({ listPlatformNodes: listPlatformNodesMock, })); -vi.mock("../services/chatChannelNameService.js", () => ({ +vi.mock("../services/chatChannelService.js", () => ({ + archiveChatChannel: archiveChatChannelMock, claimFirstMessageGeneratedNameAttempt: claimFirstMessageGeneratedNameAttemptMock, completeFirstMessageGeneratedName: completeFirstMessageGeneratedNameMock, - deleteChatChannelName: deleteChatChannelNameMock, insertFirstMessageExactNameIfMissing: insertFirstMessageExactNameIfMissingMock, - listChatChannelNames: listChatChannelNamesMock, - upsertChatChannelName: upsertChatChannelNameMock, + listChatChannels: listChatChannelsMock, + upsertChatChannel: upsertChatChannelMock, })); vi.mock('../services/localAgentLoopService.js', () => ({ @@ -240,9 +250,9 @@ describe("chat routes", () => { completeFirstMessageGeneratedNameMock.mockResolvedValue(null); insertFirstMessageExactNameIfMissingMock.mockReset(); insertFirstMessageExactNameIfMissingMock.mockResolvedValue(null); - listChatChannelNamesMock.mockClear(); - upsertChatChannelNameMock.mockClear(); - deleteChatChannelNameMock.mockClear(); + archiveChatChannelMock.mockClear(); + listChatChannelsMock.mockClear(); + upsertChatChannelMock.mockClear(); generateWithUserConfigMock.mockReset(); generateWithUserConfigMock.mockResolvedValue({ provider: "anthropic", @@ -943,28 +953,30 @@ describe("chat routes", () => { expect(differentSession.status).toBe(200); }); - it("lists persisted channel names", async () => { - listChatChannelNamesMock.mockResolvedValueOnce([ + it("lists persisted chat channels", async () => { + listChatChannelsMock.mockResolvedValueOnce([ { channelId: "channel-1", threadId: null, + scopeType: "channel", displayName: "重命名频道", + archivedAt: null, createdAt: "2026-04-18T08:00:00.000Z", updatedAt: "2026-04-18T08:01:00.000Z", }, ] as any); - const response = await fetch(`${baseUrl}/api/chat/channel-names`); + const response = await fetch(`${baseUrl}/api/chat/channels`); expect(response.status).toBe(200); const body = (await response.json()) as { - channelNames?: Array<{ channelId: string; displayName: string }>; + channels?: Array<{ channelId: string; displayName: string }>; }; - expect(body.channelNames?.[0]?.channelId).toBe("channel-1"); - expect(body.channelNames?.[0]?.displayName).toBe("重命名频道"); + expect(body.channels?.[0]?.channelId).toBe("channel-1"); + expect(body.channels?.[0]?.displayName).toBe("重命名频道"); }); - it("upserts channel name when displayName is non-empty", async () => { - const response = await fetch(`${baseUrl}/api/chat/channel-names`, { + it("upserts channel when displayName is non-empty", async () => { + const response = await fetch(`${baseUrl}/api/chat/channels`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -974,16 +986,16 @@ describe("chat routes", () => { }); expect(response.status).toBe(200); - expect(upsertChatChannelNameMock).toHaveBeenCalledWith("user-123", { + expect(upsertChatChannelMock).toHaveBeenCalledWith("user-123", { channelId: "channel-1", threadId: null, displayName: "新频道名", }); - expect(deleteChatChannelNameMock).not.toHaveBeenCalled(); + expect(archiveChatChannelMock).not.toHaveBeenCalled(); }); - it("upserts subsection name when threadId is provided", async () => { - const response = await fetch(`${baseUrl}/api/chat/channel-names`, { + it("upserts thread channel row when threadId is provided", async () => { + const response = await fetch(`${baseUrl}/api/chat/channels`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -994,15 +1006,15 @@ describe("chat routes", () => { }); expect(response.status).toBe(200); - expect(upsertChatChannelNameMock).toHaveBeenCalledWith("user-123", { + expect(upsertChatChannelMock).toHaveBeenCalledWith("user-123", { channelId: "channel-1", threadId: "sub-1", displayName: "新分区名", }); }); - it("deletes channel name mapping when displayName is null", async () => { - const response = await fetch(`${baseUrl}/api/chat/channel-names`, { + it("rejects channel upsert when displayName is null", async () => { + const response = await fetch(`${baseUrl}/api/chat/channels`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -1011,13 +1023,28 @@ describe("chat routes", () => { }), }); + expect(response.status).toBe(400); + expect(upsertChatChannelMock).not.toHaveBeenCalled(); + expect(archiveChatChannelMock).not.toHaveBeenCalled(); + }); + + it("archives channel row through explicit lifecycle endpoint", async () => { + const response = await fetch(`${baseUrl}/api/chat/channels/archive`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + channelId: "channel-1", + displayName: "Archived Channel", + }), + }); + expect(response.status).toBe(200); - expect(deleteChatChannelNameMock).toHaveBeenCalledWith( - "user-123", - "channel-1", - null, - ); - expect(upsertChatChannelNameMock).not.toHaveBeenCalled(); + expect(archiveChatChannelMock).toHaveBeenCalledWith("user-123", { + channelId: "channel-1", + threadId: null, + displayName: "Archived Channel", + }); + expect(upsertChatChannelMock).not.toHaveBeenCalled(); }); it('writes tool_call_start DB message when onToolCallStart callback is triggered', async () => { @@ -1398,7 +1425,8 @@ describe("chat routes", () => { const expectedInvalidations = [ { kind: 'chat.channelNames', channelId: 'channel-1', threadId: null }, - { kind: 'chat.scopes', channelId: 'channel-1', threadId: 'thread-1' }, + { kind: 'chat.channelNames', channelId: 'channel-1', threadId: 'thread-1' }, + { kind: 'chat.scopeSettings', channelId: 'channel-1', threadId: 'thread-1' }, ]; expect(upsertMessagesMock).toHaveBeenCalledWith('user-123', [ expect.objectContaining({ diff --git a/apps/node_backend/src/routes/chat.ts b/apps/node_backend/src/routes/chat.ts index f532b77b..26cc1a75 100644 --- a/apps/node_backend/src/routes/chat.ts +++ b/apps/node_backend/src/routes/chat.ts @@ -27,13 +27,13 @@ import { upsertChatScopeSetting, } from "../services/chatRouterService.js"; import { + archiveChatChannel, claimFirstMessageGeneratedNameAttempt, completeFirstMessageGeneratedName, - deleteChatChannelName, insertFirstMessageExactNameIfMissing, - listChatChannelNames, - upsertChatChannelName, -} from "../services/chatChannelNameService.js"; + listChatChannels, + upsertChatChannel, +} from "../services/chatChannelService.js"; import { getPlatformNodeByNodeId, listPlatformNodes, @@ -124,12 +124,22 @@ function invalidationsForToolResult( switch (stepResult.toolName) { case "chat_channel_create": - return [{ kind: "chat.scopes", ...(channelId ? { channelId } : {}) }]; + if (!channelId) return []; + return [ + { kind: "chat.channelNames", channelId, threadId: null }, + { kind: "chat.scopeSettings", channelId, threadId: null }, + ]; case "chat_thread_create": + if (!channelId) return []; return [ { - kind: "chat.scopes", - ...(channelId ? { channelId } : {}), + kind: "chat.channelNames", + channelId, + ...(threadId ? { threadId } : {}), + }, + { + kind: "chat.scopeSettings", + channelId, ...(threadId ? { threadId } : {}), }, ]; @@ -1782,7 +1792,7 @@ router.get("/scope-settings", async (req: AuthRequest, res: Response) => { } }); -router.get("/channel-names", async (req: AuthRequest, res: Response) => { +router.get("/channels", async (req: AuthRequest, res: Response) => { try { const userId = req.userId; if (!userId) { @@ -1790,15 +1800,15 @@ router.get("/channel-names", async (req: AuthRequest, res: Response) => { return; } - const channelNames = await listChatChannelNames(userId); - res.json({ channelNames }); + const channels = await listChatChannels(userId); + res.json({ channels }); } catch (error) { - console.error("List chat channel names error:", error); + console.error("List chat channels error:", error); res.status(500).json({ error: "Internal server error" }); } }); -router.put("/channel-names", async (req: AuthRequest, res: Response) => { +router.put("/channels", async (req: AuthRequest, res: Response) => { try { const userId = req.userId; if (!userId) { @@ -1827,19 +1837,60 @@ router.put("/channel-names", async (req: AuthRequest, res: Response) => { } if (!displayNameRaw) { - const deleted = await deleteChatChannelName(userId, channelId, threadId); - res.json({ deleted: deleted.deleted }); + res.status(400).json({ + error: "Invalid payload: displayName is required", + }); return; } - const setting = await upsertChatChannelName(userId, { + const setting = await upsertChatChannel(userId, { channelId, threadId, displayName: displayNameRaw, }); res.json({ setting }); } catch (error) { - console.error("Upsert chat channel name error:", error); + console.error("Upsert chat channel error:", error); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.post("/channels/archive", async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId; + if (!userId) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + + const body = req.body ?? {}; + const channelId = parseSessionId(body.channelId); + const threadId = parseSessionId(body.threadId); + const displayNameRaw = + typeof body.displayName === "string" ? body.displayName.trim() : ""; + + if (!channelId) { + res.status(400).json({ + error: "Invalid payload: channelId is required", + }); + return; + } + + if (displayNameRaw.length > 255) { + res.status(400).json({ + error: "Invalid payload: displayName must be 255 characters or fewer", + }); + return; + } + + const setting = await archiveChatChannel(userId, { + channelId, + threadId, + displayName: displayNameRaw || channelId, + }); + res.json({ setting }); + } catch (error) { + console.error("Archive chat channel error:", error); res.status(500).json({ error: "Internal server error" }); } }); diff --git a/apps/node_backend/src/scripts/exportChatFixtureDb.ts b/apps/node_backend/src/scripts/exportChatFixtureDb.ts index aae16a31..f416ff11 100644 --- a/apps/node_backend/src/scripts/exportChatFixtureDb.ts +++ b/apps/node_backend/src/scripts/exportChatFixtureDb.ts @@ -14,7 +14,7 @@ const DEFAULT_TABLES = [ 'chat_messages', 'chat_sync_checkpoints', 'chat_scope_settings', - 'chat_channel_names', + 'chat_channels', 'platform_nodes', 'chat_sessions', ]; @@ -24,7 +24,7 @@ const USER_SCOPED_TABLES = new Set([ 'chat_messages', 'chat_sync_checkpoints', 'chat_scope_settings', - 'chat_channel_names', + 'chat_channels', 'platform_nodes', 'chat_sessions', ]); diff --git a/apps/node_backend/src/services/chatAsyncTransportService.test.ts b/apps/node_backend/src/services/chatAsyncTransportService.test.ts index eb974974..590e8360 100644 --- a/apps/node_backend/src/services/chatAsyncTransportService.test.ts +++ b/apps/node_backend/src/services/chatAsyncTransportService.test.ts @@ -272,31 +272,12 @@ describe('chatAsyncTransportService', () => { ]); }); - it('listUserScopes includes configured scopes without message history', async () => { + it('listUserScopes excludes configured scopes without message history', async () => { queryMock.mockResolvedValueOnce({ rows: [], rowCount: 0 }); - queryMock.mockResolvedValueOnce({ - rows: [ - { - scope_type: 'channel', - channel_id: 'openclaw-lab', - thread_id: '', - router: 'openclaw', - created_at: '2026-04-17T07:00:00.000Z', - updated_at: '2026-04-17T07:00:00.000Z', - }, - ], - rowCount: 1, - }); const scopes = await listUserScopes('u-1'); - expect(scopes).toEqual([ - { - channelId: 'openclaw-lab', - threadId: 'main', - sessionId: 'session:openclaw-lab:main', - lastActivityAt: null, - }, - ]); + expect(scopes).toEqual([]); + expect(queryMock).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/node_backend/src/services/chatAsyncTransportService.ts b/apps/node_backend/src/services/chatAsyncTransportService.ts index 786e2284..514d4421 100644 --- a/apps/node_backend/src/services/chatAsyncTransportService.ts +++ b/apps/node_backend/src/services/chatAsyncTransportService.ts @@ -1,7 +1,5 @@ import pool from '../db/index.js'; import { - buildChatSessionId, - listChatScopeSettings, normalizeChatThreadId, } from './chatRouterService.js'; @@ -463,8 +461,7 @@ export async function forkThread(input: ForkThreadInput): Promise { - const [result, settings] = await Promise.all([ - pool.query( + const result = await pool.query( `SELECT scope.channel_id, scope.thread_id, @@ -490,9 +487,7 @@ export async function listUserScopes(userId: string): Promise(); for (const row of result.rows) { @@ -505,21 +500,6 @@ export async function listUserScopes(userId: string): Promise { if (a.lastActivityAt && b.lastActivityAt) { return b.lastActivityAt.localeCompare(a.lastActivityAt); diff --git a/apps/node_backend/src/services/chatChannelNameService.ts b/apps/node_backend/src/services/chatChannelNameService.ts deleted file mode 100644 index 58940d19..00000000 --- a/apps/node_backend/src/services/chatChannelNameService.ts +++ /dev/null @@ -1,170 +0,0 @@ -import pool from '../db/index.js'; - -interface ChatChannelNameRow { - channel_id: string; - thread_id: string | null; - display_name: string; - source: ChatChannelNameSource; - generated_name_attempted_at: string | null; - created_at: string; - updated_at: string; -} - -export type ChatChannelNameSource = - | 'manual' - | 'first_message_exact' - | 'first_message_generated'; - -export interface ChatChannelNameSetting { - channelId: string; - threadId: string | null; - displayName: string; - source: ChatChannelNameSource; - generatedNameAttemptedAt: string | null; - createdAt: string; - updatedAt: string; -} - -export interface ChatChannelNameInput { - channelId: string; - threadId?: string | null; - displayName: string; - source?: ChatChannelNameSource; -} - -function normalizeThreadId(threadId?: string | null): string { - const normalizedThreadId = threadId?.trim() ?? ""; - return normalizedThreadId === "main" ? "" : normalizedThreadId; -} - -function toDto(row: ChatChannelNameRow): ChatChannelNameSetting { - return { - channelId: row.channel_id, - threadId: row.thread_id || null, - displayName: row.display_name, - source: row.source ?? 'manual', - generatedNameAttemptedAt: row.generated_name_attempted_at, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; -} - -export async function listChatChannelNames( - userId: string, -): Promise { - const result = await pool.query( - `SELECT channel_id, thread_id, display_name, source, generated_name_attempted_at, created_at, updated_at - FROM chat_channel_names - WHERE user_id = $1 - ORDER BY channel_id ASC, thread_id ASC`, - [userId], - ); - return result.rows.map(toDto); -} - -export async function upsertChatChannelName( - userId: string, - input: ChatChannelNameInput, -): Promise { - const threadId = normalizeThreadId(input.threadId); - const source = input.source ?? 'manual'; - const result = await pool.query( - `INSERT INTO chat_channel_names (user_id, channel_id, thread_id, display_name, source) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (user_id, channel_id, thread_id) - DO UPDATE SET - display_name = EXCLUDED.display_name, - source = EXCLUDED.source, - generated_name_attempted_at = chat_channel_names.generated_name_attempted_at, - updated_at = CURRENT_TIMESTAMP - RETURNING channel_id, thread_id, display_name, source, generated_name_attempted_at, created_at, updated_at`, - [userId, input.channelId, threadId, input.displayName, source], - ); - return toDto(result.rows[0]); -} - -export async function insertFirstMessageExactNameIfMissing( - userId: string, - input: { - channelId: string; - threadId: string; - displayName: string; - }, -): Promise { - const threadId = normalizeThreadId(input.threadId); - if (!threadId) return null; - const result = await pool.query( - `INSERT INTO chat_channel_names (user_id, channel_id, thread_id, display_name, source) - VALUES ($1, $2, $3, $4, 'first_message_exact') - ON CONFLICT (user_id, channel_id, thread_id) DO NOTHING - RETURNING channel_id, thread_id, display_name, source, generated_name_attempted_at, created_at, updated_at`, - [userId, input.channelId, threadId, input.displayName], - ); - return result.rows[0] ? toDto(result.rows[0]) : null; -} - -export async function claimFirstMessageGeneratedNameAttempt( - userId: string, - input: { - channelId: string; - threadId: string; - }, -): Promise { - const threadId = normalizeThreadId(input.threadId); - if (!threadId) return null; - const result = await pool.query( - `UPDATE chat_channel_names - SET generated_name_attempted_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP - WHERE user_id = $1 - AND channel_id = $2 - AND thread_id = $3 - AND source = 'first_message_exact' - AND generated_name_attempted_at IS NULL - RETURNING channel_id, thread_id, display_name, source, generated_name_attempted_at, created_at, updated_at`, - [userId, input.channelId, threadId], - ); - return result.rows[0] ? toDto(result.rows[0]) : null; -} - -export async function completeFirstMessageGeneratedName( - userId: string, - input: { - channelId: string; - threadId: string; - displayName: string; - }, -): Promise { - const threadId = normalizeThreadId(input.threadId); - if (!threadId) return null; - const result = await pool.query( - `UPDATE chat_channel_names - SET display_name = $4, - source = 'first_message_generated', - updated_at = CURRENT_TIMESTAMP - WHERE user_id = $1 - AND channel_id = $2 - AND thread_id = $3 - AND source = 'first_message_exact' - AND generated_name_attempted_at IS NOT NULL - RETURNING channel_id, thread_id, display_name, source, generated_name_attempted_at, created_at, updated_at`, - [userId, input.channelId, threadId, input.displayName], - ); - return result.rows[0] ? toDto(result.rows[0]) : null; -} - -export async function deleteChatChannelName( - userId: string, - channelId: string, - threadId?: string | null, -): Promise<{ deleted: boolean }> { - const storageThreadId = normalizeThreadId(threadId); - const result = await pool.query( - `DELETE FROM chat_channel_names - WHERE user_id = $1 - AND channel_id = $2 - AND thread_id = $3`, - [userId, channelId, storageThreadId], - ); - return { deleted: (result.rowCount ?? 0) > 0 }; -} diff --git a/apps/node_backend/src/services/chatChannelService.ts b/apps/node_backend/src/services/chatChannelService.ts new file mode 100644 index 00000000..334d7dcc --- /dev/null +++ b/apps/node_backend/src/services/chatChannelService.ts @@ -0,0 +1,201 @@ +import pool from '../db/index.js'; + +interface ChatChannelRow { + channel_id: string; + thread_id: string | null; + scope_type: ChatChannelScopeType; + display_name: string; + source: ChatChannelSource; + generated_name_attempted_at: string | null; + archived_at: string | null; + created_at: string; + updated_at: string; +} + +export type ChatChannelScopeType = 'channel' | 'thread'; + +export type ChatChannelSource = + | 'manual' + | 'first_message_exact' + | 'first_message_generated' + | 'tool'; + +export interface ChatChannelSetting { + channelId: string; + threadId: string | null; + scopeType: ChatChannelScopeType; + displayName: string; + source: ChatChannelSource; + generatedNameAttemptedAt: string | null; + archivedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface ChatChannelInput { + channelId: string; + threadId?: string | null; + displayName: string; + source?: ChatChannelSource; +} + +function normalizeThreadId(threadId?: string | null): string { + const normalizedThreadId = threadId?.trim() ?? ''; + return normalizedThreadId === 'main' ? '' : normalizedThreadId; +} + +function scopeTypeForThreadId(threadId: string): ChatChannelScopeType { + return threadId ? 'thread' : 'channel'; +} + +function toDto(row: ChatChannelRow): ChatChannelSetting { + return { + channelId: row.channel_id, + threadId: row.thread_id || null, + scopeType: row.scope_type ?? scopeTypeForThreadId(row.thread_id ?? ''), + displayName: row.display_name, + source: row.source ?? 'manual', + generatedNameAttemptedAt: row.generated_name_attempted_at, + archivedAt: row.archived_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export async function listChatChannels( + userId: string, +): Promise { + const result = await pool.query( + `SELECT channel_id, thread_id, scope_type, display_name, source, + generated_name_attempted_at, archived_at, created_at, updated_at + FROM chat_channels + WHERE user_id = $1 + AND archived_at IS NULL + ORDER BY channel_id ASC, thread_id ASC`, + [userId], + ); + return result.rows.map(toDto); +} + +export async function upsertChatChannel( + userId: string, + input: ChatChannelInput, +): Promise { + const threadId = normalizeThreadId(input.threadId); + const source = input.source ?? 'manual'; + const scopeType = scopeTypeForThreadId(threadId); + const result = await pool.query( + `INSERT INTO chat_channels (user_id, channel_id, thread_id, scope_type, display_name, source, archived_at) + VALUES ($1, $2, $3, $4, $5, $6, NULL) + ON CONFLICT (user_id, channel_id, thread_id) + DO UPDATE SET + scope_type = EXCLUDED.scope_type, + display_name = EXCLUDED.display_name, + source = EXCLUDED.source, + archived_at = NULL, + generated_name_attempted_at = chat_channels.generated_name_attempted_at, + updated_at = CURRENT_TIMESTAMP + RETURNING channel_id, thread_id, scope_type, display_name, source, + generated_name_attempted_at, archived_at, created_at, updated_at`, + [userId, input.channelId, threadId, scopeType, input.displayName, source], + ); + return toDto(result.rows[0]); +} + +export async function archiveChatChannel( + userId: string, + input: ChatChannelInput, +): Promise { + const threadId = normalizeThreadId(input.threadId); + const scopeType = scopeTypeForThreadId(threadId); + const source = input.source ?? 'manual'; + const result = await pool.query( + `INSERT INTO chat_channels (user_id, channel_id, thread_id, scope_type, display_name, source, archived_at) + VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP) + ON CONFLICT (user_id, channel_id, thread_id) + DO UPDATE SET + scope_type = EXCLUDED.scope_type, + display_name = COALESCE(NULLIF(EXCLUDED.display_name, ''), chat_channels.display_name), + archived_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + RETURNING channel_id, thread_id, scope_type, display_name, source, + generated_name_attempted_at, archived_at, created_at, updated_at`, + [userId, input.channelId, threadId, scopeType, input.displayName, source], + ); + return toDto(result.rows[0]); +} + +export async function insertFirstMessageExactNameIfMissing( + userId: string, + input: { + channelId: string; + threadId: string; + displayName: string; + }, +): Promise { + const threadId = normalizeThreadId(input.threadId); + if (!threadId) return null; + const result = await pool.query( + `INSERT INTO chat_channels (user_id, channel_id, thread_id, scope_type, display_name, source) + VALUES ($1, $2, $3, 'thread', $4, 'first_message_exact') + ON CONFLICT (user_id, channel_id, thread_id) DO NOTHING + RETURNING channel_id, thread_id, scope_type, display_name, source, + generated_name_attempted_at, archived_at, created_at, updated_at`, + [userId, input.channelId, threadId, input.displayName], + ); + return result.rows[0] ? toDto(result.rows[0]) : null; +} + +export async function claimFirstMessageGeneratedNameAttempt( + userId: string, + input: { + channelId: string; + threadId: string; + }, +): Promise { + const threadId = normalizeThreadId(input.threadId); + if (!threadId) return null; + const result = await pool.query( + `UPDATE chat_channels + SET generated_name_attempted_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE user_id = $1 + AND channel_id = $2 + AND thread_id = $3 + AND source = 'first_message_exact' + AND generated_name_attempted_at IS NULL + AND archived_at IS NULL + RETURNING channel_id, thread_id, scope_type, display_name, source, + generated_name_attempted_at, archived_at, created_at, updated_at`, + [userId, input.channelId, threadId], + ); + return result.rows[0] ? toDto(result.rows[0]) : null; +} + +export async function completeFirstMessageGeneratedName( + userId: string, + input: { + channelId: string; + threadId: string; + displayName: string; + }, +): Promise { + const threadId = normalizeThreadId(input.threadId); + if (!threadId) return null; + const result = await pool.query( + `UPDATE chat_channels + SET display_name = $4, + source = 'first_message_generated', + updated_at = CURRENT_TIMESTAMP + WHERE user_id = $1 + AND channel_id = $2 + AND thread_id = $3 + AND source = 'first_message_exact' + AND generated_name_attempted_at IS NOT NULL + AND archived_at IS NULL + RETURNING channel_id, thread_id, scope_type, display_name, source, + generated_name_attempted_at, archived_at, created_at, updated_at`, + [userId, input.channelId, threadId, input.displayName], + ); + return result.rows[0] ? toDto(result.rows[0]) : null; +} diff --git a/apps/node_backend/src/services/localAgentLoopService.test.ts b/apps/node_backend/src/services/localAgentLoopService.test.ts index 5c2fe294..e40b7b1e 100644 --- a/apps/node_backend/src/services/localAgentLoopService.test.ts +++ b/apps/node_backend/src/services/localAgentLoopService.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; const upsertChatScopeSettingMock = vi.fn(); -const upsertChatChannelNameMock = vi.fn(); +const upsertChatChannelMock = vi.fn(); const listChatScopeSettingsMock = vi.fn().mockResolvedValue([]); -const listChatChannelNamesMock = vi.fn().mockResolvedValue([]); +const listChatChannelsMock = vi.fn().mockResolvedValue([]); vi.mock('./chatRouterService.js', () => ({ CHAT_ROUTER_LOCAL: 'local', @@ -17,9 +17,9 @@ vi.mock('./chatRouterService.js', () => ({ listChatScopeSettings: listChatScopeSettingsMock, })); -vi.mock('./chatChannelNameService.js', () => ({ - upsertChatChannelName: upsertChatChannelNameMock, - listChatChannelNames: listChatChannelNamesMock, +vi.mock('./chatChannelService.js', () => ({ + upsertChatChannel: upsertChatChannelMock, + listChatChannels: listChatChannelsMock, })); vi.mock('./todoService.js', () => ({ @@ -114,7 +114,7 @@ vi.mock('./scheduledActionService.js', () => ({ describe('localAgentLoopService', () => { beforeEach(() => { upsertChatScopeSettingMock.mockReset(); - upsertChatChannelNameMock.mockReset(); + upsertChatChannelMock.mockReset(); }); it('rejects tools outside allowlist', async () => { @@ -178,6 +178,11 @@ describe('localAgentLoopService', () => { expect(result.ok).toBe(true); expect(result.data?.sessionId).toBe('session:ops:main'); + expect(upsertChatChannelMock).toHaveBeenCalledWith('u-1', { + channelId: 'ops', + displayName: 'ops', + source: 'tool', + }); }); it('creates thread scope for create tool', async () => { @@ -199,6 +204,12 @@ describe('localAgentLoopService', () => { expect(result.ok).toBe(true); expect(result.data?.sessionId).toBe('session:ops:bugs'); + expect(upsertChatChannelMock).toHaveBeenCalledWith('u-1', { + channelId: 'ops', + threadId: 'bugs', + displayName: 'bugs', + source: 'tool', + }); }); it('runs tool calls in sequence and stops on failure', async () => { @@ -287,7 +298,7 @@ describe('localAgentLoopService', () => { }); it('renames a channel via chat_channel_rename tool', async () => { - upsertChatChannelNameMock.mockResolvedValue({ + upsertChatChannelMock.mockResolvedValue({ channelId: 'default', displayName: 'My Channel', createdAt: '2026-05-09T00:00:00.000Z', @@ -308,7 +319,7 @@ describe('localAgentLoopService', () => { expect(result.ok).toBe(true); expect(result.data?.channelId).toBe('default'); expect(result.data?.displayName).toBe('My Channel'); - expect(upsertChatChannelNameMock).toHaveBeenCalledWith('u-1', { + expect(upsertChatChannelMock).toHaveBeenCalledWith('u-1', { channelId: 'default', displayName: 'My Channel', }); @@ -331,7 +342,7 @@ describe('localAgentLoopService', () => { }); it('renames a thread via chat_thread_rename tool', async () => { - upsertChatChannelNameMock.mockResolvedValue({ + upsertChatChannelMock.mockResolvedValue({ channelId: 'default', threadId: 'bugs', displayName: 'Bug Reports', @@ -354,7 +365,7 @@ describe('localAgentLoopService', () => { expect(result.data?.channelId).toBe('default'); expect(result.data?.threadId).toBe('bugs'); expect(result.data?.displayName).toBe('Bug Reports'); - expect(upsertChatChannelNameMock).toHaveBeenCalledWith('u-1', { + expect(upsertChatChannelMock).toHaveBeenCalledWith('u-1', { channelId: 'default', threadId: 'bugs', displayName: 'Bug Reports', diff --git a/apps/node_backend/src/services/localAgentLoopService.ts b/apps/node_backend/src/services/localAgentLoopService.ts index 1e3f0667..5493fdd6 100644 --- a/apps/node_backend/src/services/localAgentLoopService.ts +++ b/apps/node_backend/src/services/localAgentLoopService.ts @@ -7,7 +7,7 @@ import { upsertChatScopeSetting, listChatScopeSettings, } from './chatRouterService.js'; -import { upsertChatChannelName, listChatChannelNames } from './chatChannelNameService.js'; +import { listChatChannels, upsertChatChannel } from './chatChannelService.js'; import { listTodos, createTodo, @@ -343,13 +343,20 @@ async function createChannelScope(params: { channelId: string; }): Promise { const router: ChatRouter = CHAT_ROUTER_LOCAL; - await upsertChatScopeSetting(params.userId, { - scopeType: 'channel', - channelId: params.channelId, - threadId: null, - router, - instructions: null, - }); + await Promise.all([ + upsertChatChannel(params.userId, { + channelId: params.channelId, + displayName: params.channelId, + source: 'tool', + }), + upsertChatScopeSetting(params.userId, { + scopeType: 'channel', + channelId: params.channelId, + threadId: null, + router, + instructions: null, + }), + ]); return { ok: true, @@ -368,7 +375,7 @@ async function renameChannel(params: { channelId: string; displayName: string; }): Promise { - const setting = await upsertChatChannelName(params.userId, { + const setting = await upsertChatChannel(params.userId, { channelId: params.channelId, displayName: params.displayName, }); @@ -391,7 +398,7 @@ async function renameThread(params: { threadId: string; displayName: string; }): Promise { - const setting = await upsertChatChannelName(params.userId, { + const setting = await upsertChatChannel(params.userId, { channelId: params.channelId, threadId: params.threadId, displayName: params.displayName, @@ -417,13 +424,21 @@ async function createThreadScope(params: { }): Promise { const router: ChatRouter = CHAT_ROUTER_LOCAL; const normalizedThreadId = normalizeChatThreadId(params.threadId); - await upsertChatScopeSetting(params.userId, { - scopeType: 'thread', - channelId: params.channelId, - threadId: normalizedThreadId, - router, - instructions: null, - }); + await Promise.all([ + upsertChatChannel(params.userId, { + channelId: params.channelId, + threadId: normalizedThreadId, + displayName: normalizedThreadId, + source: 'tool', + }), + upsertChatScopeSetting(params.userId, { + scopeType: 'thread', + channelId: params.channelId, + threadId: normalizedThreadId, + router, + instructions: null, + }), + ]); return { ok: true, @@ -441,25 +456,26 @@ async function createThreadScope(params: { async function listChannels(params: { userId: string; }): Promise { - const [scopes, names] = await Promise.all([ + const [scopes, registryRows] = await Promise.all([ listChatScopeSettings(params.userId), - listChatChannelNames(params.userId), + listChatChannels(params.userId), ]); - // Build the name map and collect unique channel IDs in one pass over `names`. const nameMap = new Map(); const channelIds = new Set(); - for (const n of names) { - nameMap.set(n.channelId, n.displayName); - channelIds.add(n.channelId); + for (const channel of registryRows) { + if (channel.scopeType !== 'channel') continue; + nameMap.set(channel.channelId, channel.displayName); + channelIds.add(channel.channelId); } - for (const s of scopes) channelIds.add(s.channelId); const channels = Array.from(channelIds) .sort() .map((channelId) => { const scope = scopes.find((s) => s.scopeType === 'channel' && s.channelId === channelId); - const threadCount = scopes.filter((s) => s.scopeType === 'thread' && s.channelId === channelId).length; + const threadCount = registryRows.filter( + (row) => row.scopeType === 'thread' && row.channelId === channelId, + ).length; return { channelId, displayName: nameMap.get(channelId) ?? null, @@ -480,18 +496,31 @@ async function getChannel(params: { userId: string; channelId: string; }): Promise { - const [scopes, names] = await Promise.all([ + const [scopes, channels] = await Promise.all([ listChatScopeSettings(params.userId), - listChatChannelNames(params.userId), + listChatChannels(params.userId), ]); - const nameMap = new Map(names.map((n) => [n.channelId, n.displayName])); + const nameMap = new Map( + channels + .filter((n) => n.scopeType === 'channel') + .map((n) => [n.channelId, n.displayName]), + ); const channelScope = scopes.find( (s) => s.scopeType === 'channel' && s.channelId === params.channelId, ); - const threads = scopes - .filter((s) => s.scopeType === 'thread' && s.channelId === params.channelId) - .map((s) => ({ threadId: s.threadId, instructions: s.instructions })); + const instructionsByThreadId = new Map( + scopes + .filter((s) => s.scopeType === 'thread' && s.channelId === params.channelId && s.threadId) + .map((s) => [s.threadId as string, s.instructions]), + ); + const threads = channels + .filter((s) => s.scopeType === 'thread' && s.channelId === params.channelId && s.threadId) + .map((s) => ({ + threadId: s.threadId, + displayName: s.displayName, + instructions: s.threadId ? (instructionsByThreadId.get(s.threadId) ?? null) : null, + })); return { ok: true, @@ -510,23 +539,22 @@ async function listThreads(params: { userId: string; channelId: string; }): Promise { - const [scopes, names] = await Promise.all([ + const [scopes, channels] = await Promise.all([ listChatScopeSettings(params.userId), - listChatChannelNames(params.userId), + listChatChannels(params.userId), ]); - const threadNameMap = new Map( - names - .filter((n) => n.channelId === params.channelId && n.threadId) - .map((n) => [n.threadId as string, n.displayName]), + const instructionsByThreadId = new Map( + scopes + .filter((s) => s.scopeType === 'thread' && s.channelId === params.channelId && s.threadId) + .map((s) => [s.threadId as string, s.instructions]), ); - - const threads = scopes - .filter((s) => s.scopeType === 'thread' && s.channelId === params.channelId) + const threads = channels + .filter((s) => s.scopeType === 'thread' && s.channelId === params.channelId && s.threadId) .map((s) => ({ threadId: s.threadId, - displayName: s.threadId ? (threadNameMap.get(s.threadId) ?? null) : null, - instructions: s.instructions, + displayName: s.displayName, + instructions: s.threadId ? (instructionsByThreadId.get(s.threadId) ?? null) : null, })); return { @@ -542,15 +570,19 @@ async function getThread(params: { channelId: string; threadId: string; }): Promise { - const [scopes, names] = await Promise.all([ + const [scopes, channels] = await Promise.all([ listChatScopeSettings(params.userId), - listChatChannelNames(params.userId), + listChatChannels(params.userId), ]); - const nameMap = new Map(names.map((n) => [n.channelId, n.displayName])); + const nameMap = new Map( + channels + .filter((n) => n.scopeType === 'channel') + .map((n) => [n.channelId, n.displayName]), + ); const threadNameMap = new Map( - names - .filter((n) => n.threadId) + channels + .filter((n) => n.scopeType === 'thread' && n.threadId) .map((n) => [`${n.channelId}:${n.threadId}`, n.displayName]), ); const threadDisplayName = threadNameMap.get(`${params.channelId}:${params.threadId}`) ?? null; diff --git a/docs/code_maps/feature_map.yaml b/docs/code_maps/feature_map.yaml index a7f95367..8f2e716f 100644 --- a/docs/code_maps/feature_map.yaml +++ b/docs/code_maps/feature_map.yaml @@ -54,7 +54,7 @@ products: - 当 writeSeq 因更新语义与原始对话顺序冲突时,列表仍应按 seqId 保持“先问后答”。 - local 与 plugin 路由均通过 `/api/chat/respond` 先入库 user query 并返回 async accepted,再由后台异步写入 assistant 回复。 - 发送后 user query 仍先显示“已接受”状态;assistant 身份占位可在 SSE dispatch 占位到达时提前出现,但 assistant 正文气泡仍应等待真实内容开始到达。 - - Agent tool 结果可通过 message metadata 的 typed invalidations 驱动前端局部刷新;普通对话完成不应全量请求 scopes/channel-names/scope-settings。 + - Agent tool 结果可通过 message metadata 的 typed invalidations 驱动前端局部刷新;普通对话完成不应全量请求 scopes/channels/scope-settings。 - user query 气泡应显示两段状态:入库后先显示第一枚 ✓;AI 开始回复后显示第二枚状态,local/其他远端为第二枚 ✓,OpenClaw plugin 为单色路由状态图标。 - user query 的时间/线程与投递状态应显示在气泡内;长按气泡可弹出“复制/分叉(待开发)/重发(待开发)”菜单,并在底部显示 message id 与 task id。 - 发送消息后,user query 由后端 /respond 端点统一持久化;前端不执行独立入库请求。 @@ -66,7 +66,7 @@ products: - 当向上浏览历史消息后,输入框上方应出现向下箭头按钮;点击后应快速跳转到最新消息。 - 刷新页面后,已发送过消息的频道仍会在侧边栏中显示并可继续会话。 - 新建频道时必须输入频道名,且重复名称会被拦截。 - - 新建频道确认后,即使还没有发送消息,刷新页面后侧边栏仍应从 `chat_channel_names` 恢复该频道名称。 + - 新建频道确认后,即使还没有发送消息,刷新页面后侧边栏仍应从 `chat_channels` 恢复该频道名称。 - Navigation 的 Channels tab 创建按钮显示为 `New Channel`。 - 默认频道显示为 `Default Channel`。 - Navigation 的 Channels tab 列表项不显示 leading 图标;长按频道菜单显示 `Rename` / `Archive`。 @@ -96,8 +96,8 @@ products: - 当路由为 OpenClaw 时,输入框左下角应额外出现 `/` 斜杠命令按钮,选择命令后应自动填充到输入框。 - 未来新增的 plugin 平台(例如 Hermes)应复用 plugin 路由,平台差异留在 node/plugin 配置层。 - 顶部频道名称应支持下拉切换频道,切换后分区标签与分区列表需同步刷新为目标频道的数据。 - - AI tool 创建 thread 或改名当前 channel 后,客户端应通过同步 scopes/channel names 刷新顶部名称与 Thread 列表,不需要整页刷新。 - - 非主 Thread 首次收到用户消息时,后端应先在 `chat_channel_names` 写入 `source=first_message_exact` 的可读名;本轮 assistant 成功完成后仅尝试一次 `first_message_generated`,成功或 fallback 后通过 typed invalidation 刷新 Thread 名称。 + - AI tool 创建 thread 或改名当前 channel 后,客户端应通过同步 scopes/channels 刷新顶部名称与 Thread 列表,不需要整页刷新。 + - 非主 Thread 首次收到用户消息时,后端应先在 `chat_channels` 写入 `source=first_message_exact` 的可读名;本轮 assistant 成功完成后仅尝试一次 `first_message_generated`,成功或 fallback 后通过 typed invalidation 刷新 Thread 名称。 - 顶部频道下拉菜单顶部应显示 `Rename` / `Archive` 功能分组;默认频道下这两个功能禁用,非默认频道下应调用频道改名与归档持久化路径;频道很多时菜单应限制在合理最大高度内并支持内部滚动。 - 频道名称右侧应内嵌分区下拉按钮(显示当前分区名+下箭头),且该菜单与频道标题左对齐,不再使用右上角三个点。 - Thread 菜单主线程显示为 `Thread`;菜单应先显示 `New Thread` 功能组;在子线程内应显示小号浅色“当前组”标题及 `Rename` / `Archive` 操作;主线程不显示当前组操作;底部列表组显示主线程和已有子线程;菜单弹出动画应保持快速缩放+淡入。 @@ -383,7 +383,7 @@ products: - '非目标 plugin 对其他 plugin 创建的 assistant message 执行 PATCH 时,应返回 403。' - '/api/v1/platform/events/ack 禁止 body 中传 pluginId,合法 ACK 可重复调用并返回成功。' - '/api/config/nodes/:nodeId/agents?sourcePlatform=openclaw 在请求时即时执行 OpenClaw CLI 查询 agents,并用于 plugin 路由的 OpenClaw @ 下拉菜单。' - - '调用 /api/chat/channel-names 可保存并读取每个用户的频道自定义名称。' + - '调用 /api/chat/channels 可保存并读取每个用户的频道与 Thread 注册信息;调用 /api/chat/channels/archive 可归档并隐藏对应行。' - '使用 /api/config/nodes 创建多个节点后,不同节点的 token 应绑定不同 pluginId。' - '/api/v1/platform/messages 写入后,消息 metadata 应包含 nodeId/nodeName(当 pluginId 可映射到节点)。' - '高频访问 `/api/config?category=llm` 不应因应用层全局 IP 限流触发 429。' diff --git a/docs/code_maps/logic_map.yaml b/docs/code_maps/logic_map.yaml index dd3300cd..844b7c22 100644 --- a/docs/code_maps/logic_map.yaml +++ b/docs/code_maps/logic_map.yaml @@ -46,7 +46,7 @@ index: - Quick Login - LOCAL_LLM_CONFIG_ENABLED - channel New UI harness - - chat_channel_names checkpoint + - chat_channels checkpoint - evidence checkpoint browser harness - local manual AI test environment - channel dropdown height evidence case @@ -105,7 +105,7 @@ index: - apps/mobile_chat_app/lib/features/agents/agents_screen.dart - apps/mobile_chat_app/lib/services/authenticated_api_client.dart - apps/mobile_chat_app/lib/features/chat/chat_history_api_service.dart - - apps/node_backend/src/services/chatChannelNameService.ts + - apps/node_backend/src/services/chatChannelService.ts - apps/mobile_chat_app/lib/features/settings/llm_config_service.dart - apps/mobile_chat_app/lib/features/chat/chat_message_sort.dart - apps/mobile_chat_app/lib/features/chat/widgets/composer_bar.dart @@ -121,6 +121,7 @@ index: - apps/node_backend/src/llm/llm_service.ts - apps/node_backend/src/db/migrations/019_scope_chat_channel_names.sql - apps/node_backend/src/db/migrations/020_chat_channel_name_source.sql + - apps/node_backend/src/db/migrations/025_create_chat_channels.sql doc_index: - docs/intro.md - docs/product/overview.md @@ -241,7 +242,7 @@ index: - 若 default `/chat/respond` 回退为同步阻塞路径,会导致“先入库再异步回复”契约失效并放大接口延迟。 - 若 dispatch 占位与真实 assistant 正文未复用同一 `messageId`/合并链路,可能出现重复头像行或占位残留。 - 若 dispatch 占位被渲染成普通空 assistant 气泡,用户会误判 AI 已经输出正文并干扰两段状态图标语义。 - - 若 SSE 终态消息不区分变更类型就全量刷新 topology,会把普通对话 completion 放大成多次 scopes/channel-names/scope-settings 请求并触发 429。 + - 若 SSE 终态消息不区分变更类型就全量刷新 topology,会把普通对话 completion 放大成多次 scopes/channels/scope-settings 请求并触发 429。 - tool-driven UI 刷新应依赖 message metadata invalidations;新增会改变频道、线程、scope settings 或资源列表的工具时必须同步维护 invalidation mapping。 - 若前端回退到“全量重放 upsert”,会再次引入 write_seq 噪声推进与重复写入风险。 - channel 会话若错误暴露 thread 路由菜单,用户可能误写 thread scope 配置并造成路由预期不一致。 @@ -257,7 +258,7 @@ index: - 若发送追问时只依赖历史滚动标记而不是当前视野是否看到最新回复结尾,用户读旧回复时可能被强制跳走,或读完后发送无法回到新消息。 - 若初始历史批次没有 user 消息时仍套用 user 锚点 padding,最后一段 AI/tool/status 内容会被不自然地抬高。 - 若初始历史加载回退为全量历史而非最后 20 条,会放大长会话进入页面的渲染和滚动定位成本。 - - 如果仅从历史 scope 恢复频道列表,用户刚新建但尚未发送消息的频道会在刷新后从侧边栏消失,即使 `chat_channel_names` 已持久化。 + - 如果仅从历史 scope 恢复频道列表,用户刚新建但尚未发送消息的频道会在刷新后从侧边栏消失;可见频道与 Thread 必须来自 `chat_channels` registry。 - 若移动端没有可写的本地 Agents 目录,登录后的 chat setup 会在历史会话加载前失败,表现为同账号移动端看不到网页历史数据。 - 若 iOS 本地路径解析依赖 `HOME` 环境变量,真机启动会在 chat setup 阶段失败并阻断历史会话加载。 - 若本地自定义 Agent 文件读取与线上会话初始化共用同一个失败边界,单个本地文件系统异常会误伤频道和历史加载。 @@ -492,7 +493,7 @@ index: - apps/node_backend/src/services/chatRouterService.ts - apps/node_backend/src/services/platformNodeService.ts - apps/node_backend/src/services/openclawAgentRuntimeService.ts - - apps/node_backend/src/services/chatChannelNameService.ts + - apps/node_backend/src/services/chatChannelService.ts - apps/node_backend/src/middleware/platformAuth.ts - apps/node_backend/src/services/platformIntegrationService.ts - apps/node_backend/src/db/migrations/012_add_node_id_to_chat_scope_settings.sql diff --git a/docs/tasks/done/2026-06-23-17-45-CST-chat-channels-replace-channel-names.md b/docs/tasks/done/2026-06-23-17-45-CST-chat-channels-replace-channel-names.md new file mode 100644 index 00000000..bc51a9c6 --- /dev/null +++ b/docs/tasks/done/2026-06-23-17-45-CST-chat-channels-replace-channel-names.md @@ -0,0 +1,50 @@ +# Chat Channels Replace Channel Names + +## Background + +The current chat sidebar and thread model uses `chat_channel_names` as the persisted name store for both channels and thread-like subsections. Archive currently calls the channel-name endpoint with `displayName: null`, which is semantically wrong because archive is a lifecycle state, not a name deletion. + +Threads do not need a separate `threads` table. In the current product model, a thread is a scoped chat surface under a channel. The storage identity can be represented by `(user_id, channel_id, thread_id)`. + +`/api/chat/scopes` currently reconstructs channels and threads from message/task history and scope settings. That should stop being the source of visible sidebar entities. It can remain as an activity query, but it must not create channel or thread rows in the UI. + +## Goals + +- Replace `chat_channel_names` with a single `chat_channels` registry for both top-level channels and thread/subsection scopes. +- Add explicit lifecycle state through `archived_at` instead of overloading nullable display names. +- Make channel and thread visibility depend on `chat_channels`, not historical message/task fallback. +- Keep storage identity separate from display naming. +- Avoid adding a separate `threads` table. + +## Implementation Plan + +1. Add a `chat_channels` database table keyed by `(user_id, channel_id, thread_id)`. +2. Store top-level channels with the normalized main-thread value for `thread_id`. +3. Store thread/subsection rows with the same `channel_id` and a non-main `thread_id`. +4. Include `display_name`, `scope_type`, `archived_at`, `source`, `generated_name_attempted_at`, `created_at`, and `updated_at`. +5. Migrate existing `chat_channel_names` rows into `chat_channels`. +6. Replace channel-name backend services and routes with channel lifecycle routes for create, rename, archive, and list. +7. Update frontend channel and thread hydration to list visible rows from `chat_channels`. +8. Downgrade `/api/chat/scopes` to return activity metadata only; frontend may join that activity onto existing `chat_channels` rows but must not create visible channels or threads from scopes. +9. Remove frontend archive behavior that calls the name endpoint with `displayName: null`. +10. Stop reading and writing `chat_channel_names` after migration. +11. Update code maps for the changed chat feature entry points, backend business logic, and tests. + +## Acceptance Criteria + +- Creating a channel persists a `chat_channels` top-level row. +- Renaming a channel updates `chat_channels.display_name`. +- Archiving a channel sets `chat_channels.archived_at` and removes it from the active sidebar list. +- Creating a thread persists a `chat_channels` row under the parent channel with a non-main `thread_id`. +- Renaming a thread updates the corresponding `chat_channels.display_name`. +- Archiving a thread sets `chat_channels.archived_at` and removes it from the active thread list. +- `/api/chat/scopes` no longer causes old message/task history to appear as visible sidebar channels by itself. +- Existing channel names from `chat_channel_names` are preserved after migration. +- The frontend no longer sends archive requests to `/api/chat/channel-names`. + +## Validation Commands + +- `./tools/init_dev_env.sh` +- `npm test` +- `cd apps/mobile_chat_app && flutter analyze` +- `cd apps/mobile_chat_app && flutter test` From 60d2a0bf32c3c579740824a904333caa3014a59d Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 23 Jun 2026 22:57:50 +0800 Subject: [PATCH 3/3] Add Turso branch migration test skill --- .../SKILL.md | 167 ++++++++++++++++++ .gitignore | 1 + .../migrations/025_create_chat_channels.sql | 4 +- 3 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/bricks-turso-branch-migration-test/SKILL.md diff --git a/.agents/skills/bricks-turso-branch-migration-test/SKILL.md b/.agents/skills/bricks-turso-branch-migration-test/SKILL.md new file mode 100644 index 00000000..c446d910 --- /dev/null +++ b/.agents/skills/bricks-turso-branch-migration-test/SKILL.md @@ -0,0 +1,167 @@ +--- +name: bricks-turso-branch-migration-test +description: Use when validating Bricks database migrations against a Turso Cloud branch/test database copied from the current cloud database, then running local backend and Flutter Web against that branch database for manual verification. Covers Turso Cloud CLI setup, branch creation with --from-db, local secret-safe env handling, migration verification, and local app startup. +--- + +# Bricks Turso Branch Migration Test + +Use this skill when a schema migration must be validated with realistic cloud data before merging or deploying to production. + +## Safety Rules + +- Never print `TURSO_AUTH_TOKEN`, `BRICKS_TEST_TOKEN`, `JWT_SECRET`, `ENCRYPTION_KEY`, or provider API keys. +- Keep branch/test DB env in an uncommitted local file such as `.env.migration-test.local`. +- Do not use `.env.local` directly for migration validation unless the user explicitly wants to migrate that database. +- After the branch DB has been migrated, start local backend with `AUTO_MIGRATE=false`. +- Prefer keeping old tables as deprecated rollback backups in risky migrations; drop them only in a separate cleanup migration after production validation. + +## Cloud CLI + +There are two similarly named tools: + +- Homebrew `turso` installs `tursodb`, the embedded SQL shell. It is not the Cloud management CLI. +- Turso Cloud CLI is installed as `~/.turso/turso` by `https://get.tur.so/install.sh` or by downloading `homebrew-tap_Darwin_arm64.tar.gz` from `tursodatabase/homebrew-tap`. + +Check the Cloud CLI: + +```bash +~/.turso/turso --version +~/.turso/turso auth whoami +``` + +If not logged in, the user must run: + +```bash +~/.turso/turso auth login +``` + +## Create A Branch/Test Database + +1. Derive the source database name from `.env.local` without printing secrets: + + ```bash + zsh -lc 'set -a; source .env.local; set +a; node -e "const u=new URL(process.env.TURSO_DATABASE_URL||\"\"); console.log(u.hostname);"' + ``` + +2. If the hostname includes an org suffix, switch to the owning org: + + ```bash + ~/.turso/turso org list + ~/.turso/turso org switch + ~/.turso/turso db list + ``` + +3. Create a short branch DB name. Turso DB names must be at most 26 characters: + + ```bash + ~/.turso/turso db create bricks-mig-0623a --from-db database-bricks --wait + ``` + +4. Get the branch DB URL and token. Do not print the token: + + ```bash + ~/.turso/turso db show bricks-mig-0623a --url + ~/.turso/turso db tokens create bricks-mig-0623a > /private/tmp/bricks-mig-0623a.token + ``` + +## Prepare Local Test Env + +Create `.env.migration-test.local` from `.env.local`, but replace Turso URL/token with the branch DB values: + +```bash +zsh -lc 'set -a; source .env.local; set +a; token=$(tr -d "\n" < /private/tmp/bricks-mig-0623a.token); umask 077; cat > .env.migration-test.local < +TURSO_AUTH_TOKEN=$token +JWT_SECRET=$JWT_SECRET +ENCRYPTION_KEY=$ENCRYPTION_KEY +FIXTURE_USER_ID=$FIXTURE_USER_ID +BRICKS_TEST_TOKEN=$BRICKS_TEST_TOKEN +PORT=3010 +BRICKS_LOCAL_DEV=true +LOCAL_LLM_CONFIG_ENABLED=false +AUTO_MIGRATE=true +EOF' +``` + +Use `LOCAL_LLM_CONFIG_ENABLED=false` unless the test specifically needs local provider keys. If it is `true` without a valid `LOCAL_LLM_PROVIDER` and API key, chat can fail with `Invalid local LLM provider`. + +## Run Migration On Branch DB + +Run the migration directly against the branch DB: + +```bash +cd apps/node_backend +zsh -lc 'set -a; source ../../.env.migration-test.local; set +a; npx tsx src/db/migrate.ts' +``` + +Verify the result with parameterized SQL so shell quoting does not corrupt string literals: + +```bash +cd apps/node_backend +zsh -lc 'set -a; source ../../.env.migration-test.local; set +a; node --input-type=module - <<'"'"'NODE'"'"' +import { createClient } from "@libsql/client"; +const client = createClient({ url: process.env.TURSO_DATABASE_URL, authToken: process.env.TURSO_AUTH_TOKEN }); +async function scalar(sql, args = []) { + const r = await client.execute({ sql, args }); + return Object.values(r.rows[0] ?? {})[0]; +} +const tables = await client.execute({ + sql: "SELECT name FROM sqlite_schema WHERE name IN (?, ?) ORDER BY name", + args: ["chat_channels", "chat_channel_names"], +}); +const migrated = await scalar("SELECT COUNT(*) AS c FROM chat_channels"); +const legacy = await scalar("SELECT COUNT(*) AS c FROM chat_channel_names"); +const migration = await scalar("SELECT COUNT(*) AS c FROM migrations WHERE version = ?", ["025"]); +console.log(JSON.stringify({ + tables: tables.rows.map((r) => r.name), + chatChannelsRows: migrated, + legacyRows: legacy, + migration025Rows: migration, +}, null, 2)); +NODE' +``` + +Expected for the `chat_channels` migration: + +- `chat_channels` exists. +- deprecated `chat_channel_names` still exists as rollback backup. +- row counts match immediately after migration. +- migration `025` is recorded once. + +## Start Local App Against Branch DB + +After migration validation, restart backend with migration disabled: + +```bash +cd apps/node_backend +zsh -lc 'set -a; source ../../.env.migration-test.local; set +a; PORT=3010 AUTO_MIGRATE=false npm run dev' +``` + +Start Flutter Web: + +```bash +cd apps/mobile_chat_app +zsh -lc 'set -a; source ../../.env.migration-test.local; set +a; PATH=/Users/admin/.local/tools/flutter/bin:$PATH flutter run \ + -d web-server \ + --web-hostname 127.0.0.1 \ + --web-port 8082 \ + --dart-define=BRICKS_API_BASE_URL=http://127.0.0.1:3010 \ + --dart-define=BRICKS_TEST_TOKEN="$BRICKS_TEST_TOKEN"' +``` + +Hand off: + +- Frontend: `http://127.0.0.1:8082` +- Backend: `http://127.0.0.1:3010` +- DB: branch/test DB name +- `AUTO_MIGRATE=false` for running backend +- Quick Login writes as `FIXTURE_USER_ID` + +## Smoke Checks + +- Backend health: `curl -sS http://127.0.0.1:3010/api/health` +- Authenticated `/api/chat/channels` returns 200 and expected row count; do not print tokens. +- Channel create, rename, archive, refresh. +- Thread create, rename, archive, refresh. +- Existing historical message loading still works. +- `/api/chat/scopes` activity does not recreate hidden sidebar entities by itself. diff --git a/.gitignore b/.gitignore index 7ab4caae..6d6b3822 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,4 @@ pubspec_overrides.yaml !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages !/dev/ci/**/Gemfile.lock.github/aw/logs/ .worktree/ +.env.migration-test.local diff --git a/apps/node_backend/src/db/migrations/025_create_chat_channels.sql b/apps/node_backend/src/db/migrations/025_create_chat_channels.sql index fea14b40..5a9a9246 100644 --- a/apps/node_backend/src/db/migrations/025_create_chat_channels.sql +++ b/apps/node_backend/src/db/migrations/025_create_chat_channels.sql @@ -60,4 +60,6 @@ CREATE TRIGGER update_chat_channels_updated_at FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -DROP TABLE IF EXISTS chat_channel_names; +-- Keep chat_channel_names as a deprecated rollback backup. +-- Runtime code no longer reads or writes this table after this migration. +-- Drop it only in a separate cleanup migration after production validation.