feat: make AI Assistant a tileable workspace pane (not a rail view)

Open it from the toolbar ( AI Chat) and lay it out next to a Serial
session with Layout (1×2, 2×2…) — chat on one side, watch the terminal
on the other, like Claude Code / Kiro. Previously it was a full-page
Activity Rail view that couldn't sit beside a live session.

WorkspaceView.Session is now abstracted to hold either a SessionPage or
an AiChatView via a Content property; group/log/script actions skip AI
panes. Removed the standalone Ai rail view; Settings → AI Assistant
(provider config) is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 21:57:09 +08:00
co-authored by Claude Fable 5
parent d9e0e8fabf
commit 6e955ba247
6 changed files with 72 additions and 45 deletions
+2 -1
View File
@@ -185,7 +185,8 @@ public sealed class AboutView : UserControl
[
new("0.6.0", new DateOnly(2026, 7, 5), "Built-in AI Assistant — drive serial & PDU in plain language",
[
"New ✨ AI Assistant view: chat in plain language to send serial commands and control PDU outlets — e.g. \"attach to COM3, send help and show me the reply\" or \"connect to the PDU and power-cycle outlet 3\".",
"New ✨ AI Chat: click ✨ AI Chat in the toolbar to open an AI pane, then use Layout (1×2, 2×2…) to sit it right next to a Serial session — chat on one side while you watch the terminal on the other, just like Claude Code / Kiro.",
"Talk in plain language to send serial commands and control PDU outlets — e.g. \"attach to COM3, send help and show me the reply\" or \"connect to the PDU and power-cycle outlet 3\".",
"Bring your own AI endpoint: point it at any OpenAI-compatible server (a local Ollama, a LiteLLM gateway, your company's gateway, or OpenAI). Set it up in Settings → AI Assistant; leave it blank and the assistant simply stays off.",
"Your API key is stored in Windows Credential Manager, never in a settings file — and no endpoint ships inside the app, so a copy you hand to someone else has the assistant disabled by default.",
"The AI drives your existing open Serial session, and everything it sends shows up in that terminal tagged [AI] so you always see what it did.",
+1 -2
View File
@@ -10,7 +10,7 @@ namespace ETTerms.App;
/// </summary>
public sealed class ActivityRail : UserControl
{
public enum RailView { Terminal, Ai, Status, Settings, About }
public enum RailView { Terminal, Status, Settings, About }
public event EventHandler<RailView>? ViewSelected;
@@ -23,7 +23,6 @@ public sealed class ActivityRail : UserControl
private static readonly (RailView view, string glyph, string tip)[] Items =
{
(RailView.Terminal, "▤", "Terminal"),
(RailView.Ai, "✨", "AI Assistant"),
(RailView.Status, "⚡", "Status"),
(RailView.Settings, "⚙", "Settings"),
(RailView.About, "", "About"),
-5
View File
@@ -14,7 +14,6 @@ public partial class MainForm : Form
private readonly ActivityRail _rail = new();
private readonly ConnectionSidebar _sidebar = new();
private readonly WorkspaceView _workspace = new();
private readonly AiChatView _aiView = new();
private readonly StatusView _statusView = new();
private readonly SettingsView _settings = new();
private readonly AboutView _about = new();
@@ -47,14 +46,12 @@ public partial class MainForm : Form
private void BuildLayout()
{
Controls.Add(_workspace); // Fill
Controls.Add(_aiView); // Fill (hidden)
Controls.Add(_statusView); // Fill (hidden)
Controls.Add(_settings); // Fill (hidden)
Controls.Add(_about); // Fill (hidden)
Controls.Add(_sidebar); // Left (內側)
Controls.Add(_rail); // Left (最外側)
_aiView.Visible = false;
_statusView.Visible = false;
_settings.Visible = false;
_about.Visible = false;
@@ -74,11 +71,9 @@ public partial class MainForm : Form
_statusLabel.Text = $"View: {view}";
_sidebar.Visible = view == ActivityRail.RailView.Terminal;
_workspace.Visible = view == ActivityRail.RailView.Terminal;
_aiView.Visible = view == ActivityRail.RailView.Ai;
_statusView.Visible = view == ActivityRail.RailView.Status;
_settings.Visible = view == ActivityRail.RailView.Settings;
_about.Visible = view == ActivityRail.RailView.About;
if (view == ActivityRail.RailView.Ai) _aiView.RefreshProvider();
AppLogger.LogInfo($"View selected: {view}");
};
+64 -32
View File
@@ -17,7 +17,11 @@ public sealed class WorkspaceView : UserControl
{
public required string Title;
public required bool IsSsh;
public required SessionPage Page;
public SessionPage? Page; // 連線分頁(serial/ssh/shell);AI 分頁時為 null
public AiChatView? Ai; // AI 聊天分頁;連線分頁時為 null
public bool IsAi => Ai != null;
/// <summary>可塞進格子的內容控制項(SessionPage 或 AiChatView,二擇一)。</summary>
public Control Content => (Control?)Page ?? Ai!;
public Rectangle TabBounds;
public Rectangle CloseRect;
public bool Alert; // 背景分頁出現高亮關鍵字 → 標紅點,切到該分頁時清除
@@ -61,6 +65,17 @@ public sealed class WorkspaceView : UserControl
Dock = DockStyle.Fill, BackColor = Theme.RailBack,
Padding = new Padding(8, 6, 8, 6), WrapContents = false
};
// ── ✨ New AI Chat(開一個 AI 分頁,可用 Layout 與 serial 並排)──
var aiBtn = MakeActionButton("✨ AI Chat", 92, 0, (_, _) => OpenAiPane());
aiBtn.ForeColor = Theme.Accent;
aiBtn.FlatAppearance.BorderColor = Theme.Accent;
_toolbar.Controls.Add(aiBtn);
_toolbar.Controls.Add(new Label
{
Text = "│", AutoSize = true, ForeColor = Theme.Border,
Font = Theme.UiFont, Margin = new Padding(4, 6, 4, 0)
});
_toolbar.Controls.Add(new Label
{
Text = "Layout", AutoSize = true, ForeColor = Theme.TextDim,
@@ -126,6 +141,17 @@ public sealed class WorkspaceView : UserControl
Relayout();
}
/// <summary>開啟一個 AI Assistant 分頁(跟連線分頁一樣可用 Layout 並排,與 serial 同時使用)。</summary>
public void OpenAiPane()
{
var view = new AiChatView();
var s = new Session { Title = "AI Assistant", IsSsh = false, Ai = view };
_sessions.Add(s);
_active = s;
Relayout();
view.RefreshProvider();
}
private void OnConnectFailed(Session s, string msg)
{
if (IsDisposed) return;
@@ -150,17 +176,18 @@ public sealed class WorkspaceView : UserControl
private void CloseSession(Session s)
{
int idx = _sessions.IndexOf(s);
s.Page.Parent = null;
s.Content.Parent = null;
_sessions.Remove(s);
s.Page.Dispose();
s.Content.Dispose();
if (_active == s) _active = _sessions.Count > 0 ? _sessions[Math.Min(idx, _sessions.Count - 1)] : null;
RefreshGroupLabels();
Relayout();
}
// ── Group 管理 ───────────────────────────────────────────
// ── Group 管理AI 分頁無 Group─────────────────────────
private void SetSessionGroup(Session s, int group)
{
if (s.Page == null) return;
s.Page.Group = group;
RefreshGroupLabels();
Relayout();
@@ -171,17 +198,17 @@ public sealed class WorkspaceView : UserControl
for (int g = 1; g <= 3; g++)
{
char letter = 'A';
foreach (var s in _sessions.Where(x => x.Page.Group == g))
s.Page.GroupLabel = $"Group{g}-{letter++}";
foreach (var s in _sessions.Where(x => x.Page != null && x.Page.Group == g))
s.Page!.GroupLabel = $"Group{g}-{letter++}";
}
foreach (var s in _sessions.Where(x => x.Page.Group == 0))
s.Page.GroupLabel = "";
foreach (var s in _sessions.Where(x => x.Page != null && x.Page.Group == 0))
s.Page!.GroupLabel = "";
}
// ── 依目前 Layout 把分頁鋪進格子 ─────────────────────────
private void Relayout()
{
foreach (var s in _sessions) s.Page.Parent = null; // 先卸下(保留存活)
foreach (var s in _sessions) s.Content.Parent = null; // 先卸下(保留存活)
_body.SuspendLayout();
for (int i = _body.Controls.Count - 1; i >= 0; i--)
{
@@ -229,14 +256,16 @@ public sealed class WorkspaceView : UserControl
private Control MakeCell(Session s, bool withLabel)
{
var cell = new Panel { Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack, Margin = Padding.Empty, Padding = new Padding(1) };
s.Page.Dock = DockStyle.Fill;
s.Page.Visible = true;
cell.Controls.Add(s.Page); // Fill 先加
s.Content.Dock = DockStyle.Fill;
s.Content.Visible = true;
cell.Controls.Add(s.Content); // Fill 先加
if (withLabel)
{
string labelText = string.IsNullOrEmpty(s.Page.GroupLabel)
? $"{(s.IsSsh ? "🖧" : "🔌")} {s.Title}"
: $"{(s.IsSsh ? "🖧" : "🔌")} {s.Title} [{s.Page.GroupLabel}]";
string icon = s.IsAi ? "✨" : s.IsSsh ? "🖧" : "🔌";
string glabel = s.Page?.GroupLabel ?? "";
string labelText = string.IsNullOrEmpty(glabel)
? $"{icon} {s.Title}"
: $"{icon} {s.Title} [{glabel}]";
var lbl = new Label
{
Dock = DockStyle.Bottom, Height = 22,
@@ -253,8 +282,8 @@ public sealed class WorkspaceView : UserControl
private void FocusActive()
{
if (_active?.Page is { IsDisposed: false } p && p.IsHandleCreated)
p.Focus();
if (_active?.Content is { IsDisposed: false } c && c.IsHandleCreated)
c.Focus();
}
// ── 頂部 Tab 列 ──────────────────────────────────────────
@@ -276,7 +305,7 @@ public sealed class WorkspaceView : UserControl
{
foreach (var s in _sessions)
{
if (s.TabBounds.Contains(e.Location)) { ShowGroupMenu(s, e.Location); return; }
if (s.TabBounds.Contains(e.Location)) { if (!s.IsAi) ShowGroupMenu(s, e.Location); return; }
}
return;
}
@@ -303,7 +332,7 @@ public sealed class WorkspaceView : UserControl
menu.Items.Add("Group 2", null, (_, _) => SetSessionGroup(s, 2));
menu.Items.Add("Group 3", null, (_, _) => SetSessionGroup(s, 3));
// Check current
int current = s.Page.Group;
int current = s.Page?.Group ?? 0;
((ToolStripMenuItem)menu.Items[current]).Checked = true;
menu.Show(_tabStrip, pt);
}
@@ -368,8 +397,10 @@ public sealed class WorkspaceView : UserControl
if (drag)
using (var pen = new Pen(Theme.Accent, 1))
g.DrawRectangle(pen, new Rectangle(s.TabBounds.Left, s.TabBounds.Top, s.TabBounds.Width - 1, s.TabBounds.Height - 1));
// 警示中的背景分頁:型別圓點改紅色,切過去看時清除
using (var dot = new SolidBrush(s.Alert ? Color.FromArgb(235, 85, 85) : s.IsSsh ? Theme.SshColor : Theme.SerialColor))
// 警示中的背景分頁:型別圓點改紅色,切過去看時清除。AI 分頁用 accent 紫。
Color dotColor = s.Alert ? Color.FromArgb(235, 85, 85)
: s.IsAi ? Theme.Accent : s.IsSsh ? Theme.SshColor : Theme.SerialColor;
using (var dot = new SolidBrush(dotColor))
g.FillEllipse(dot, s.TabBounds.Left + 9, StripH / 2 - 4, 8, 8);
var tr = new Rectangle(s.TabBounds.Left + 22, s.TabBounds.Top, s.TabBounds.Width - 22 - CloseSz - 10, StripH);
TextRenderer.DrawText(g, s.Title, Theme.UiFont, tr, active ? Theme.Text : Theme.TextDim,
@@ -415,19 +446,20 @@ public sealed class WorkspaceView : UserControl
// ── Log All(一次開/關所有分頁側錄) ──────────────────────
private void OnToggleLogAll(object? sender, EventArgs e)
{
if (_sessions.Count == 0)
var loggable = _sessions.Where(s => s.Page != null).Select(s => s.Page!).ToList();
if (loggable.Count == 0)
{
MessageBox.Show(this, "No open sessions to log.", "Log All", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
// 只要還有分頁沒在側錄 → 全部開始;否則全部停止。
bool startAll = _sessions.Any(s => !s.Page.IsLogging);
// 只要還有分頁沒在側錄 → 全部開始;否則全部停止。AI 分頁無側錄)
bool startAll = loggable.Any(p => !p.IsLogging);
if (startAll)
{
int failed = 0;
foreach (var s in _sessions)
if (!s.Page.StartLog()) failed++;
foreach (var p in loggable)
if (!p.StartLog()) failed++;
SetLogAllActive(true);
if (failed > 0)
MessageBox.Show(this, $"{failed} session(s) failed to start logging. See app log for details.",
@@ -435,7 +467,7 @@ public sealed class WorkspaceView : UserControl
}
else
{
foreach (var s in _sessions) s.Page.StopLog();
foreach (var p in loggable) p.StopLog();
SetLogAllActive(false);
}
}
@@ -451,8 +483,8 @@ public sealed class WorkspaceView : UserControl
private async void OnRunAllSerial(object? sender, EventArgs e)
{
var serials = _sessions
.Where(s => !s.IsSsh && s.Page.IsSerial && !s.Page.IsScriptRunning)
.Select(s => s.Page)
.Where(s => s.Page != null && !s.IsSsh && s.Page.IsSerial && !s.Page.IsScriptRunning)
.Select(s => s.Page!)
.ToList();
if (serials.Count == 0)
@@ -472,8 +504,8 @@ public sealed class WorkspaceView : UserControl
private async void OnRunGroup(int group)
{
var members = _sessions
.Where(s => s.Page.Group == group && !s.Page.IsScriptRunning)
.Select(s => s.Page)
.Where(s => s.Page != null && s.Page.Group == group && !s.Page.IsScriptRunning)
.Select(s => s.Page!)
.ToList();
if (members.Count == 0)
@@ -491,7 +523,7 @@ public sealed class WorkspaceView : UserControl
protected override void Dispose(bool disposing)
{
if (disposing)
foreach (var s in _sessions) { try { s.Page.Dispose(); } catch { } }
foreach (var s in _sessions) { try { s.Content.Dispose(); } catch { } }
base.Dispose(disposing);
}