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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,21 @@ Hard rules:

## Runtime Ownership

### Presentation boundaries

- `AppShell` coordinates the workspace; `AppSidebar` and `WorkspaceWelcome`
own navigation and empty-state presentation. `useSidebarResize` owns drag
cleanup without depending on a store.
- `SessionToolbar` adapts runtime/settings values to connection, view-switching,
and display-control components. These children only accept props and emit intent.
- `SendPanel` binds session settings to `SendOptions` and `useSendComposer`.
The composer snapshots payload/mode before asynchronous work, allows only one
pending preparation/send, and preserves drafts edited while a send is pending.
- CSS tokens and `getThemeOverrides` provide matching dark/light colors to native
controls and Naive UI, including concrete colors for Naive's alpha calculations.

### Services

- **Workspace application service:** sole durability owner for sessions, frames,
layout metadata, and feature projections. SQLite workspaces use schema v5;
older on-disk schemas are rejected (no in-place migration).
Expand Down
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,34 @@ All notable changes to bbcom are documented here. The format is based on

## [Unreleased]

## [1.2.0] - 2026-09-08

### Changed

- Decoupled workspace navigation, connection controls, send composition, and
MCUmgr panels; refreshed dark/light themes and compact-window layouts.
- Reworked MCUmgr system and image tools with compact actions, full-width hash
entry, and expandable results. Execution status now updates in a fixed row
without shifting tabs or content; action tooltips dismiss after activation.
- Serial controls now expose opening, closing, cancellation, reconnect stop,
and failed-close retry states while keeping toolbar positions stable.

### Fixed

- Serial open/close operations coalesce repeated requests, preserve pending
cleanup, and reject stale open or reconnect completions after cancellation.
- Send composition snapshots payload and mode, preserves newly edited drafts,
prevents duplicate submissions, and includes appended checksum bytes.
- Modbus response parsing recovers past serial noise and verifies response
identity, quantity, and write echoes before completing a transaction.
- Workspace saves account for UTF-8 and JSON escaping, transitions restore
partially stopped runtimes, and export cancellation releases file grants.
- AI event listeners detach after late registration, native cancellation cannot
lose a wakeup, and workspace changes clear stale chat bindings.
- MCUmgr rejects malformed HEX input and ignores progress from cancelled tasks.
- Paused capture clearing, full-buffer SMP updates, retained terminal search,
automatic scrolling, oversized row copying, and traffic rates remain correct.

## [1.1.0] - 2026-08-28

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "bbcom",
"private": true,
"version": "1.1.0",
"version": "1.2.0",
"type": "module",
"packageManager": "pnpm@11.11.0",
"engines": {
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "bbcom"
version = "1.1.0"
version = "1.2.0"
description = "Serial Port Assistant"
edition = "2024"
rust-version = "1.97"
Expand Down
5 changes: 4 additions & 1 deletion src-tauri/src/commands/ai/request_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,13 @@ impl AiCancellation {

pub async fn cancelled(&self) {
loop {
// Register before checking the flag: notify_waiters does not retain
// a permit for futures created after the cancellation broadcast.
let notified = self.notify.notified();
if self.cancelled.load(Ordering::Acquire) {
return;
}
self.notify.notified().await;
notified.await;
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0",
"productName": "bbcom",
"version": "1.1.0",
"version": "1.2.0",
"identifier": "com.bbcom.app",
"build": {
"beforeDevCommand": "pnpm run tauri:before-dev",
Expand Down
12 changes: 8 additions & 4 deletions src/AiWindow.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<template>
<n-config-provider :theme-overrides="themeOverrides">
<n-config-provider
:theme="appStore.theme === 'dark' ? darkTheme : null"
:theme-overrides="activeThemeOverrides"
>
<n-message-provider>
<div ref="contentEl" class="ai-window-content">
<AiPanel />
Expand All @@ -9,15 +12,16 @@
</template>

<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, onErrorCaptured, ref, watch } from 'vue';
import { NConfigProvider, NMessageProvider } from 'naive-ui';
import { computed, nextTick, onMounted, onUnmounted, onErrorCaptured, ref, watch } from 'vue';
import { darkTheme, NConfigProvider, NMessageProvider } from 'naive-ui';
import { resizeAiWindow } from '@/features/platform/native';
import AiPanel from '@/features/ai/ui/AiPanel.vue';
import { useAiWindowAuthority } from './features/ai-activity';
import { useAppStore } from '@/features/settings/store/app-store';
import { themeOverrides } from '@/design-system/naive-theme';
import { getThemeOverrides } from '@/design-system/naive-theme';

const appStore = useAppStore();
const activeThemeOverrides = computed(() => getThemeOverrides(appStore.theme));
useAiWindowAuthority({
setTheme: appStore.setTheme,
setLocale: appStore.setLocale,
Expand Down
12 changes: 8 additions & 4 deletions src/App.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<template>
<n-config-provider :theme-overrides="themeOverrides">
<n-config-provider
:theme="appStore.theme === 'dark' ? darkTheme : null"
:theme-overrides="activeThemeOverrides"
>
<n-message-provider>
<n-dialog-provider>
<AppShell />
Expand All @@ -10,15 +13,16 @@
</template>

<script setup lang="ts">
import { onErrorCaptured, watch } from 'vue';
import { NConfigProvider, NDialogProvider, NMessageProvider } from 'naive-ui';
import { computed, onErrorCaptured, watch } from 'vue';
import { darkTheme, NConfigProvider, NDialogProvider, NMessageProvider } from 'naive-ui';
import AppShell from '@/features/app-shell/ui/AppShell.vue';
import ShutdownDialog from '@/features/app-shell/ui/ShutdownDialog.vue';
import { useAiSessionBridge } from '@/features/ai/application/use-ai-session-bridge';
import { useAppStore } from '@/features/settings/store/app-store';
import { themeOverrides } from '@/design-system/naive-theme';
import { getThemeOverrides } from '@/design-system/naive-theme';

const appStore = useAppStore();
const activeThemeOverrides = computed(() => getThemeOverrides(appStore.theme));

// Reflect the theme onto <html data-theme> so the CSS variable palettes swap.
watch(
Expand Down
19 changes: 18 additions & 1 deletion src/design-system/__tests__/naive-theme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { expect, test } from 'vitest';
import { mount } from '@vue/test-utils';
import { NButton, NConfigProvider } from 'naive-ui';
import { h } from 'vue';
import { themeOverrides } from '@/design-system/naive-theme';
import { getThemeOverrides, themeOverrides } from '@/design-system/naive-theme';

// Naive-ui derives secondary/tertiary/dashed button colors in JavaScript via
// seemly's changeColor(), which throws on var() expressions. These variants
Expand Down Expand Up @@ -33,3 +33,20 @@ test('error-typed secondary buttons render under theme overrides', () => {
});
expect(wrapper.find('button').exists()).toBe(true);
});

test('light theme supplies readable concrete primary colors for solid and secondary buttons', () => {
const light = getThemeOverrides('light');
expect(light.common?.primaryColor).toBe('#087f5b');
expect(light.Button?.colorPrimary).toBe(light.common?.primaryColor);
const wrapper = mount(NConfigProvider, {
props: { themeOverrides: light },
slots: {
default: () => [
h(NButton, { type: 'primary' }, { default: () => 'Send' }),
h(NButton, { type: 'primary', secondary: true }, { default: () => 'AI' }),
],
},
});
expect(wrapper.findAll('button')).toHaveLength(2);
wrapper.unmount();
});
31 changes: 29 additions & 2 deletions src/design-system/naive-theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ export const themeOverrides: GlobalThemeOverrides = {
borderPressedPrimary: '1px solid var(--color-primary-pressed)',
// Secondary/dashed/ghost buttons run these through changeColor() to derive
// alpha variants, so var() strings would throw in seemly's rgba parser.
// The primary palette is theme-invariant (light only overrides surfaces),
// so these literals mirror colors.css for both palettes.
// These literals mirror the dark palette; getThemeOverrides supplies
// the matching concrete light palette.
colorPrimary: '#3ddc97',
colorHoverPrimary: '#5ee6aa',
colorPressedPrimary: '#26b879',
Expand Down Expand Up @@ -153,3 +153,30 @@ export const themeOverrides: GlobalThemeOverrides = {
railColor: 'var(--bg-inset)',
},
};

/** Concrete colors are required by Naive UI's alpha-color calculations. */
const lightThemeOverrides: GlobalThemeOverrides = {
...themeOverrides,
common: {
...themeOverrides.common,
primaryColor: '#087f5b',
primaryColorHover: '#096b4e',
primaryColorPressed: '#07563f',
primaryColorSuppl: '#087f5b',
successColor: '#087f5b',
errorColor: '#c22f3e',
warningColor: '#946000',
infoColor: '#1d63c5',
},
Button: {
...themeOverrides.Button,
colorPrimary: '#087f5b',
colorHoverPrimary: '#096b4e',
colorPressedPrimary: '#07563f',
colorFocusPrimary: '#096b4e',
},
};

export function getThemeOverrides(theme: 'dark' | 'light'): GlobalThemeOverrides {
return theme === 'light' ? lightThemeOverrides : themeOverrides;
}
25 changes: 20 additions & 5 deletions src/design-system/tokens/colors.css
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/**
* Semantic color tokens — palette, surfaces, text, borders, accents.
* Light-theme overrides live at the bottom; brand/direction hues are shared.
* Light-theme overrides live at the bottom, including readable accent hues.
*/

:root {
color-scheme: dark;
/* Color palette */
--color-primary: #3ddc97;
--color-primary-hover: #5ee6aa;
Expand Down Expand Up @@ -130,12 +131,26 @@

/*
* Light theme — applied via <html data-theme="light">. Only the surfaces,
* text, borders, shadows and overlays are inverted; brand/direction hues
* (mint, teal, azure) read well on both backgrounds so they are unchanged.
* text, borders, shadows and overlays are inverted. Accent foregrounds use
* deeper hues to remain readable on white surfaces.
*/
:root[data-theme='light'] {
color-scheme: light;

--color-primary: #087f5b;
--color-primary-hover: #096b4e;
--color-primary-pressed: #07563f;
--color-success: #087f5b;
--color-error: #c22f3e;
--color-warning: #946000;
--color-info: #1d63c5;
--color-tx: #087e72;
--color-rx: #1d63c5;
--accent-green-hover: #06665c;
--accent-blue-hover: #174fa0;
--accent-orange: #a54c11;
--accent-violet: #7452b8;

--color-primary-subtle: rgba(61, 220, 151, 0.16);
--color-primary-muted: rgba(61, 220, 151, 0.26);
--color-tx-subtle: rgba(45, 212, 191, 0.16);
Expand All @@ -161,7 +176,7 @@
--text-primary: #0f1620;
--text-secondary: #303a45;
--text-muted: #5c6773;
--text-dim: #8a96a3;
--text-dim: #687684;
--text-inverse: #ffffff;
--overlay-backdrop: rgba(15, 22, 32, 0.42);
--overlay-backdrop-strong: rgba(15, 22, 32, 0.68);
Expand All @@ -170,7 +185,7 @@
--border-subtle: rgba(15, 22, 32, 0.07);
--border-color: rgba(15, 22, 32, 0.11);
--border-strong: rgba(15, 22, 32, 0.18);
--border-focus: rgba(61, 220, 151, 0.7);
--border-focus: rgba(8, 127, 91, 0.7);

--accent-red-subtle: rgba(205, 60, 75, 0.12);
--accent-red-border: rgba(205, 60, 75, 0.4);
Expand Down
19 changes: 16 additions & 3 deletions src/features/ai-activity/ai-window-authority.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export function useAiWindowAuthority(
const doListen = dependencies.listen ?? listen;
const nextRequestId: () => string = dependencies.requestId ?? (() => crypto.randomUUID());
let unlisten: (() => void) | null = null;
let disposed = false;

async function requestAuthority(): Promise<void> {
const requestId = nextRequestId();
Expand All @@ -75,18 +76,30 @@ export function useAiWindowAuthority(
// Authority remains not-ready there instead of breaking the renderer.
if (typeof doListen !== 'function') return;
try {
unlisten = await doListen<unknown>(AI_BRIDGE_EVENTS.authoritySnapshot, (event) => {
const cleanup = await doListen<unknown>(AI_BRIDGE_EVENTS.authoritySnapshot, (event) => {
if (disposed) return;
const next = applyAiAuthorityEnvelope(event.payload, revision.value, target);
if (next === null) return;
revision.value = next;
ready.value = true;
});
if (disposed) {
cleanup();
return;
}
unlisten = cleanup;
await requestAuthority();
} catch {
ready.value = false;
unlisten?.();
unlisten = null;
if (!disposed) ready.value = false;
}
});
onUnmounted(() => unlisten?.());
onUnmounted(() => {
disposed = true;
unlisten?.();
unlisten = null;
});
}

return { revision, ready };
Expand Down
36 changes: 35 additions & 1 deletion src/features/ai/__tests__/ai-activity-protocol.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
// @vitest-environment happy-dom

import assert from 'node:assert/strict';
import { test } from 'vitest';
import { expect, test, vi } from 'vitest';
import { defineComponent, h } from 'vue';
import { flushPromises, mount } from '@vue/test-utils';
import {
AiActivityCancelledError,
AiActivityCenter,
applyAiAuthorityEnvelope,
createAiBridgeEnvelope,
parseAiBridgeEnvelope,
useAiWindowAuthority,
} from '@/features/ai-activity/index.ts';
import { OperationRegistry } from '@/features/platform/application/operation-registry.ts';
import { useAiWindowSession } from '@/features/ai/application/use-ai-window-session.ts';
Expand Down Expand Up @@ -295,3 +300,32 @@ test('main authority applies theme, locale and key state only at a non-stale rev
assert.deepEqual(key, { configured: true, durability: 'session' });
assert.equal(applyAiAuthorityEnvelope(authority, 9, target), null);
});

test('authority listener arriving after unmount is released without requesting state', async () => {
let completeListen!: (cleanup: () => void) => void;
const cleanup = vi.fn();
const emit = vi.fn(async () => undefined);
const wrapper = mount(
defineComponent({
setup() {
useAiWindowAuthority(
{ setTheme: vi.fn(), setLocale: vi.fn(), aiKeyStatus: { configured: false } },
{
emit,
listen: (() =>
new Promise<() => void>((resolve) => {
completeListen = resolve;
})) as never,
},
);
return () => h('div');
},
}),
);
wrapper.unmount();
completeListen(cleanup);
await flushPromises();

expect(cleanup).toHaveBeenCalledOnce();
expect(emit).not.toHaveBeenCalled();
});
Loading