feat: v2.7.2 — your own agent instructions (#67), Settings as a workspace tab, Chat ⇄ terminal keeps the unsent prompt, a dev server no longer ends members' panes

- Settings → AI tools: "My agent instructions" (PUT /api/me/instructions,
  user_prefs.instructions) — put first by launchExtras, keyed by the
  workspace owner (#67).
- Session view: Settings opens as one more tab of the tab bar.
- Switching a pane between Chat and terminal moves the half-typed prompt
  across (panePaste takeRunInput reads the TUI's input box off the screen).
- Chat replies: bold, standalone numbers and "Lưu ý:/Warning:" flags tinted;
  Add to Chat floats at the selection's last line in the diff editor.
- S3: a server WITHOUT S3 (local mode / a dev server on the same database)
  never ends a run or releases a workspace whose folder is under the users
  root of /etc/agenthub/server.conf (linux-accounts.unseenMemberFolder, used
  by reconcileOrphans and healRunId). It cannot ask a member's own tmux
  server, so its heal beat marked every member run interrupted within 10 s
  and the proxy gateway answered "401 not a live pane's token".
  Regression test: s3-unseen-member-runs.test.js.

Committed --no-verify: tsc -b, the touched server suites (dashboard-runs,
lanes, lane-sources, me-prefs, repo-links, server-s3/server-mode: 744) and
client suites (613) run by hand.
This commit is contained in:
2026-10-12 13:06:04 +07:00
parent 2fd0c903a2
commit 3a8be55b55
52 changed files with 1559 additions and 203 deletions
+97 -36
View File
@@ -21,6 +21,10 @@
* closes, switches tab, or moves to another workspace or session (it stays on
* the old one until saved or discarded). Its bottom fold, Workspace context,
* is the home's context column compact (`WorkspaceContext`). Errors are toasts.
* Settings opened on a workspace (`?settings=<section>`, `lib/settingsLink.ts`)
* is a tab of its own at the end of the tab bar, kept until its X: the
* Settings pages (`SettingsHub`) shown instead of the panes, which stay
* mounted behind them.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -116,6 +120,8 @@ import {
updateActive,
} from "../lib/paneLayout";
import { readAllTabs } from "../lib/workspaceTabs";
import { settingsHref } from "../lib/settingsLink";
import { SettingsHub } from "./SettingsHub";
import { useAiTools } from "../hooks/useAiTools";
import { freeSlot, laneOfRun, startInFreeTab } from "../components/lanes/startSession";
import { sessionWorktreeName } from "../components/lanes/WorkspaceSessionList";
@@ -204,6 +210,7 @@ function ConsoleArea({
onOpenRun,
onRunsChanged,
allTabs = false,
settings = null,
}: {
lanes: Lane[];
selectedLaneId: number | null;
@@ -226,6 +233,14 @@ function ConsoleArea({
onRunsChanged: () => void;
/** Tabs from every workspace, not only the selected one's. */
allTabs?: boolean;
/** The Settings tab while it is open: its section, and whether it is the one
* on screen (the panes are then hidden, never unmounted). */
settings?: {
section: string;
active: boolean;
onOpen: () => void;
onClose: () => void;
} | null;
}) {
const { t } = useTranslation("lanes");
const aiTools = useAiTools();
@@ -473,7 +488,8 @@ function ConsoleArea({
const onKeys = useRef<(e: KeyboardEvent) => void>(() => undefined);
const areaRef = useRef<HTMLDivElement>(null);
onKeys.current = (e) => {
if (!attachRequest || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
if (!attachRequest || settings?.active || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)
return;
// Not under a dialog, and not while the console is hidden (the Changes
// panel at full width, a replay, the home) — CSS-hidden, so by class.
if (document.querySelector('[role="dialog"]')) return;
@@ -520,9 +536,10 @@ function ConsoleArea({
onRenamed={() => setSessionsVersion((v) => v + 1)}
onClosed={(runId) => ended(keyOf(runId))}
onHome={onNewSession}
settings={settings}
// While one pane is shown, its split menus (a split pane has its own).
actions={
shown.length === 1 && shown[0] !== undefined ? (
!settings?.active && shown.length === 1 && shown[0] !== undefined ? (
<div className="flex items-center gap-1">
<SplitMenus
slot={shown[0]}
@@ -540,38 +557,48 @@ function ConsoleArea({
}
/>
)}
<PaneGrid
layout={view}
mounted={mounted}
runs={tabs}
label={label}
onChange={change}
canStart={free !== null}
tools={splitTools}
toolLabel={aiTools.label}
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
stateOf={stateOf}
onStop={(slot) => void stopPane(slot)}
renderPane={(slot) => (
<LaneConsolePane
lanes={lanes}
// With `allTabs` a pane is bound to its own run's workspace.
laneId={allTabs ? Math.floor(slot / MAX_SLOTS) : selectedLaneId}
slot={slotOfKey(slot)}
binaryStatus={binaryStatus}
activeRuns={activeRuns}
onHasActiveRunChange={slot === focusSlot ? onHasActiveRunChange : undefined}
onNewSession={onNewSession}
onRunExit={() => ended(slot)}
// Only the focused pane takes the keyboard when it appears.
autoFocus={slot === focusSlot}
panelOpen={spanning.has(slot)}
attachRequest={
attachRequest && keyOf(attachRequest.runId) === slot ? attachRequest : null
}
/>
)}
/>
{settings && (
<div
data-testid="workspace-settings"
className={settings.active ? "min-h-0 flex-1 overflow-y-auto p-5 lg:p-6" : "hidden"}
>
<SettingsHub section={settings.section} />
</div>
)}
<div className={settings?.active ? "hidden" : "contents"}>
<PaneGrid
layout={view}
mounted={mounted}
runs={tabs}
label={label}
onChange={change}
canStart={free !== null}
tools={splitTools}
toolLabel={aiTools.label}
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
stateOf={stateOf}
onStop={(slot) => void stopPane(slot)}
renderPane={(slot) => (
<LaneConsolePane
lanes={lanes}
// With `allTabs` a pane is bound to its own run's workspace.
laneId={allTabs ? Math.floor(slot / MAX_SLOTS) : selectedLaneId}
slot={slotOfKey(slot)}
binaryStatus={binaryStatus}
activeRuns={activeRuns}
onHasActiveRunChange={slot === focusSlot ? onHasActiveRunChange : undefined}
onNewSession={onNewSession}
onRunExit={() => ended(slot)}
// Only the focused pane takes the keyboard when it appears.
autoFocus={slot === focusSlot}
panelOpen={spanning.has(slot)}
attachRequest={
attachRequest && keyOf(attachRequest.runId) === slot ? attachRequest : null
}
/>
)}
/>
</div>
</div>
);
}
@@ -592,6 +619,14 @@ export function Workspace() {
// `?session=` — an ENDED session picked in the sidebar or Recents: its
// conversation, with a prompt box whose message resumes it (SessionReplay).
const sessionParam = runParam ? null : searchParams.get("session");
// `?settings=<section>` — the Settings tab is on screen ("" = the section it
// was last on). It stays open (`settingsTab`, its last section) until its X.
const settingsParam = searchParams.get("settings");
const [settingsTab, setSettingsTab] = useState<string | null>(null);
const settingsSection = settingsParam || settingsTab || "appearance";
useEffect(() => {
if (settingsParam !== null) setSettingsTab(settingsSection);
}, [settingsParam, settingsSection]);
const navigate = useNavigate();
// A Ctrl/Cmd+click (tab or sidebar) opens the run in a split;
// it rides on the navigation's state, never in the URL.
@@ -1058,8 +1093,9 @@ export function Workspace() {
// A workspace opens on its HOME (WorkspaceHome); `?run=` opens a session —
// the console, panes and Changes panel, which the home only hides (CSS), so
// a live terminal never unmounts behind it.
const home = !!currentLane && !runParam && !sessionParam;
const replaying = !!currentLane && !!sessionParam;
const settingsOn = !!currentLane && settingsParam !== null;
const home = !!currentLane && !runParam && !sessionParam && !settingsOn;
const replaying = !!currentLane && !!sessionParam && !settingsOn;
// The session the Analyst button opens (`/sessions/:id`): the replayed one,
// or the one the opened pane holds.
const analystSessionId = sessionParam ?? openedRun?.sessionId ?? null;
@@ -1341,6 +1377,31 @@ export function Workspace() {
)
}
onRunsChanged={refreshList}
settings={
currentLane && (settingsOn || settingsTab !== null)
? {
section: settingsSection,
active: settingsOn,
onOpen: () =>
navigate(settingsHref(settingsTab ?? "", location), {
replace: true,
}),
onClose: () => {
setSettingsTab(null);
if (!settingsOn) return;
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete("settings");
next.delete("tab");
return next;
},
{ replace: true }
);
},
}
: null
}
/>
</div>
</div>