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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user