feat(apps): app session controls — a composer seam for per-chat app state - #7573
Conversation
|
👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated. Missing sections:
Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle. |
1 similar comment
|
👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated. Missing sections:
Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle. |
982e60d to
c8dfbbe
Compare
c8dfbbe to
2bbc6df
Compare
2bbc6df to
a2e23d8
Compare
a2e23d8 to
ef89bbf
Compare
|
I've fixed the PR according to the template |
faae274 to
9082bb4
Compare
9082bb4 to
a533355
Compare
bbfca6f to
160bd0a
Compare
160bd0a to
3b1797f
Compare
3b1797f to
01b80eb
Compare
01b80eb to
d682e5f
Compare
d682e5f to
75fe358
Compare
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
An app can hold state that is meaningful per conversation and has nowhere to put the control for it, because nothing tells it which chat the user is looking at. Verified at 373b220: AppInfo is {name, version, permissions}, and every session-bearing path in the app SDK takes a slot key the app itself supplies -- ChatEmbed and ChatPanel as a prop, useChatSession derived from a workspacePath (useChatSession.ts:67). Each names a session; none observes the active one. UIConfig's four UI fields are all session-blind. Adds the manifest reference for ui.sessionControls -- the field table, the props contract a control receives, both caps, and the statusPath route -- so an app author can build a control from the docs rather than from the source. Adds the RFC recording why the existing surfaces do not fit: ui.pages is routed, so configuring a chat means leaving it; ui.overlays requires a `replaces` slot the app takes over, which cannot express two apps each contributing a chip. Its migration plan refuses to separate the schema from the reader, because shipping the field alone would add another declared-but-unread manifest field of the kind rfc-navigation-placement-seam and rfc-everything-is-an-app both document.
|
Thanks for the thorough RFC and the review iterations — the seam itself (manifest field + composer host) belongs in core, and I don't want to relitigate that. Two things need to land before this merges, though, both about the manifest being a one-way door: 1. A real consumer in KiroCrewApps first. 2. Namespace: Once both are in, the remaining items are a rebase for the current conflict and the advisory nits already on the thread. Happy to review the KiroCrewApps side when it's up. |
Adds ui.sessionControls to the manifest and the composer host that reads it. An app declares a compact control; the dashboard renders it as a chip beside the agent, model and project chips and hands it the active session's identity -- the reason the slot exists, since an app cannot otherwise discover the chat it is rendered beside. Schema is bounded and fail-soft: at most two controls per app, kebab-case id, charset- and length-bounded statusPath, and malformed entries reported by validate() rather than raised, matching how an unroutable page route is already handled. One bad control must not abort a manifest parse. The chip loads the app's ESM module through the existing import map, so the single-React guarantee holds, and mounts it inside AppApiProvider so the control inherits the app's declared api/events allowlist and gains no privilege of its own. It is keyed on the session, so switching chats remounts it and per-session state cannot leak across the switch; the open-control state also captures the slot it was opened in and the host renders only while that slot is still active, so the committed render after a chat switch mounts nothing against the next session. Its error boundary renders inline and unmounts nothing but itself, because a control sits on the path of every turn. Controls are keyed <appName>:<id>, sorted for a stable order, and the composer renders at most two across all apps. statusPath polling is refused before any fetch if the path would leave the app's own prefix, go protocol-relative, reach another origin, or corrupt the appended query string -- bounded at install and re-checked in the dashboard. A control without statusPath is never polled, none is polled before a session exists, and a failing app is not retried at the composer's expense. An app declaring no controls produces no chip, no request and no DOM change.
Problem / Motivation
An app can hold state that is meaningful per conversation and has nowhere to put
the control for it, because nothing tells the app which chat the user is looking
at.
Verified on
1d705a03f:AppInfois{ name, version, permissions }andAppApiis five HTTP verbs.supplies —
ChatEmbed/ChatPanelas a prop, anduseChatSessionderivedfrom a caller's
workspacePath(
slotName = appName + '-' + hashStr(workspacePath),useChatSession.ts:67).Each names a session; none observes the active one.
UIConfigdeclaresentry,pages,overlays,sidebar— all session-blind.UIOverlayis documented as "not routed: it floats above whatever the user islooking at".
So today an app author has two bad options: bind at a coarser grain it can name
(a workspace or folder — which cannot distinguish two chats sharing one working
directory), or put the control on a routed page and make the user navigate away
from the conversation to configure that conversation, re-stating which chat they
mean to a UI that already knows.
Why it matters
Any app whose value is per-conversation is currently either wrong or awkward. A
folder-grained binding silently applies to chats the user never meant to include,
and a routed page turns a one-click setting into a context switch out of the
conversation and back.
It also blocks a class of app rather than one app: the composer already hosts
per-chat state (agent, model, project chips), so app-contributed per-chat state is
the one obvious surface that does not exist. Left undone, every such app
reimplements a worse version of it on a page.
What changed (motivation → approach → change)
Goal — let an app bind state to the chat the user is in, and set it without
leaving that chat.
Approach, and the alternatives rejected (full reasoning in the RFC included in
this PR,
docs/request-for-change/rfc-app-session-controls.md):ui.overlays— rejected structurally. Itsreplacesfield is requiredand names a host slot the app takes over, so the model is one-app-per-slot;
session controls must be additive and compose. An overlay is also session-blind,
so it would not solve the problem even if the contribution model fitted.
ui.pagesand pass the session in the route — rejected: it makes theuser leave the conversation to configure it, and puts a session key in a URL.
every app page would receive ambient session identity whether or not it has
per-session behaviour. A declared seam is narrower — an app that wants session
identity says so in its manifest and gets it only inside the control it
declared.
What was built —
ui.sessionControls[]on the manifest, and the composer hostthat reads it. Both land together on purpose: shipping the schema alone would add
another declared-but-unread manifest field of the kind
rfc-navigation-placement-seam.mdandrfc-everything-is-an-app.mdbothdocument.
id,charset- and length-bounded
statusPath, and malformed entries reported byvalidate()rather than raised — one bad control must not abort a manifestparse, matching how an unroutable page route is already handled.
guarantee holds, no second bundler) and mounts it inside
AppApiProvider, so itinherits the app's already-declared
permissions.api/permissions.eventsandgains no privilege of its own.
SessionControlContext(
sessionKey,folderId?,folderName?,cwd)plus
onClose. It is keyed on the session, so switching chats remounts it andper-session state cannot leak across the switch; its error boundary renders
inline and unmounts nothing but itself, because a control sits on the path of
every turn.
MAX_INLINE_SESSION_CONTROLS = 2chips across all appsand drops the rest rather than overflowing — the bar shares one row with the
message input. That is a deliberate trade, and the RFC records the overflow menu
as its open question.
statusPathlets a chip reportok/warn/nonebefore it isopened, so a configured control does not look unset until first click. It is
validated at three layers and a path that would leave the app's own prefix, go
protocol-relative, reach another origin, or corrupt the query string is refused
before any fetch, not sanitized into one (
.is excluded from the charset,so
..is unrepresentable rather than filtered). Polling fails closed: a failingapp is not retried at the composer's expense.
Backward compatible in both directions:
from_dictdefaults the field to[],to_dictomits it when empty (so no existing manifest changes on disk), andbecause
sessionControlsnests insideui, a build predating this change parsessuch a manifest without error and renders no chip. An app declaring no controls
produces no chip, no request and no DOM change.
Docs:
docs/app-kit/manifest-reference.mdgains the field-table rows and aui.sessionControlssection covering the props contract, both caps and the statusroute, so a control can be built from the reference rather than from the source.
Tests
Backend —
test/test_app_session_controls.py, 37 tests:to_dict/from_dict; an absent keyyields an empty list; non-dict entries are skipped rather than fatal.
non-kebab-case
idis rejected; astatusPathfailing the charset/length boundis rejected;
ui.pagesvalidation still passes independently.statusPathis absent by default and omitted from output.Frontend — 8 suites: 96 tests across the seven session-control suites, plus one added
HAND_TESTEDentry in the existingApiClient.coverage.test.tsx:useSessionControls.test.ts— resolver: composite<appName>:<id>keying,duplicate-key dropping, stable key sort, permission lists forwarded, junk
entries survived,
statusPathnormalization (leading slash stripped;protocol-relative, traversal, cross-origin and query-corrupting paths refused),
status normalization (
ok/warn/none, unknown → none, tooltip bounded).useSessionControls.query.test.tsx— per-control query keying so two controlssharing a
statusPathare not deduped into one.SessionControlHost.test.tsx— dialog named for both control and app; errorboundary stays local; remount on session change.
appSessionStatus.test.ts— the status URL is app-scoped and refuses to escape.ChatInput.sessionControls.test.tsx/ChatPage.sessionControls.test.tsx—chip rendering, click routing by key, and no request before a session exists.
Gate results:
mypyclean across 1237 files;isortandflake8clean; the newtest file is
black --target-version py310clean;docs-lint, the de-Amazonscrub lint, the brand-name gate and the focus-cue gate all pass on this change's
added lines. The full
vitestsuite passes (1699 files).Two failures on
mainare unaffected by this change and reproduce identically atthe base commit in a clean
mainworktree:tsc -breports 4 errors inDiscoverPage.tsx/McpManagement.tsx(files this PR does not touch), andtest:electronreports1362 pass / 17 fail / 26 cancelled(updater / port /overlay suites; this PR touches no electron code).
Manual verification
Backend and resolver behaviour is unit-covered, including every refusal path for
statusPath. What unit tests do not cover, and what is therefore stilloutstanding: rendering the chip in a real browser against a gateway with an
installed app that declares a control, and confirming the tint reflects a live
statusPathresponse. That path was exercised over the API (the manifest survives/api/apps, the control module serves 200, the status route answers) but notvisually.
Screenshots / video
Captured against a gateway with an example app (
demo-env) declaring one control —{ id: "env-picker", label: "Environment", statusPath: "session-status" }.The chip in the composer, beside the existing agent/workspace chips:
Opened, showing that the control is handed the active session (
demo) and thatthe chip marks itself active while its surface is open:
Narrow viewport
Media is committed under
temp-screenshots/app-session-controls/and embedded withcommit-SHA-pinned raw URLs, per the template.
Not shown: the
ok/warnstatus tint. The example app declares astatusPathbutits backend route did not register in this throwaway install, so the chip renders in
its untinted default (
none) state — which is the correct rendering for a controlwhose status route does not answer. The tint itself is unit-covered
(
normalizeStatus, and the resolver's refusal paths).Related Issues
None — no prior issue exists for this.
CONTRIBUTING.mdasks for an issue firston anything significant, and the RFC directory asks for an RFC before the code;
this PR carries the RFC but was not preceded by an issue. Happy to open one for
the design discussion if the maintainers prefer the RFC argued there first.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)mainfailures noted under Tests are pre-existing and reproduce at the base commitdocs/app-kit/manifest-reference.md, plus the RFC and its index rowAddressed in this revision (
76cad29ec)Blocking review findings, and the doc drift the advisory reviews flagged:
errors-use-error-notice(blocking, Opus 4.8 + GPT 5.6 F2). BothSessionControlHostfailure surfaces — the control crash boundary and thebundle-load fallback — now render through
ErrorNoticewithaskAgenton,instead of hand-written
text-danger/text-muteddivs. The crash fallbackkeeps its Retry button alongside the hand-off.
Control drafts survive a global chord (blocking, GPT 5.6 F4). The chat-jump
shortcut deliberately fires on digits even inside text fields, and a slot
switch unmounts the host — so
Ctrl/Alt+digit while typing in an opencontrol discarded the draft. The dialog now isolates every key but
Escape,which still reaches the document listener that closes it. Two tests pin this at
the
windowboundary, which is where the shortcut actually listens.Malformed session controls are refused, not dropped
(
src/kiro_crew/apps/manifest.py). A non-dict entry inui.sessionControlswas discarded during parsing, so
validate()could not report it and the appinstalled clean with the control silently absent — contradicting the documented
promise that a malformed control is refused at install time. It is now retained
as an empty placeholder, which fails the required-field checks.
One spelling of the
statusPathallowlist. The regex was written twice(
api/client.tsandhooks/useSessionControls.ts). It now lives inlib/sessionControlStatusPath.ts, which both import — not in either caller,because the hook imports the client (a cycle) and ~490 test files replace the
client with a
vi.mockfactory (which would make the constantundefined).Bundle Size Gate — resolved by upstream, not by this branch. The gate failed
with the
allcatalog chunk 2,788 B over its 10,490 KB ceiling. Attribution wasmeasured: the chunk built at 10,744,548 B, and compact re-serialization put this
branch's contribution at 8,406 B (eleven translated strings across 13 catalogs),
leaving main alone at ~10,484.5 KB — under the ceiling. So the breach was ours,
and this branch briefly raised the ceiling to 10,560 KB. Upstream then raised the
same entry to 10,975 KB independently, measuring main at 10,450 KB with only 0.4%
headroom and noting that "any feature PR shipping a normal set of keys across the
13 catalogs fails the gate on its merge ref". The rebase therefore takes
upstream's ceiling and drops this branch's edit —
check-bundle-size.mjsis nolonger in this diff, and the chunk sits well inside the new budget.
Session controls carry the session identity to the SERVER (blocking, GPT 5.6,
security-fenced). A control received
sessionKeyas a prop, but the scoped APIthe SDK hands it fetched with no
X-Session-Keyheader. The backend'srestricted-session guard reads that header and fails open —
if not sk: return Falsein_is_restricted_session— so a control mounted in an incognito or guestchat was permitted exactly the persistent memory writes that mode exists to deny.
AppApiProvidernow takes an optionalsessionKeyandcreateScopedApiattachesit to every scoped request (one
jsonFetchfunnels all verbs). Optional because afull-page app surface is not session-scoped and must send nothing rather than an
invented key. Four tests pin it: present on GET and POST, absent when the host has
no session, never clobbering a caller-set header, and not dropping the JSON
content type. All 47 SDK-touching suites re-run green, since this is shared code.
Stale chips after enabling or disabling an app.
useSessionControlsheld aprivate
['session-controls']query key, which none of the four['apps']invalidation sites reached. It now shares
['apps']with the samequeryFnasthe app list and resolves through
select, which is what the shared-keyconvention in
useAppsDatarequires (one queryFn per key; the last observer toregister fetches).
A folder-query failure no longer claims the controls are unavailable. The
['chat-folders']error is now surfaced only when a control actually exists —with no chips on screen it is not a session-control problem, and labelling it one
would put an unexplained notice on every composer.
A crashed control no longer poisons its siblings (blocking, GPT 5.6,
security-fenced).
ChatPagerenderedSessionControlHostwithout akey. Thehost's error boundary holds
state.errorand nothing clears it on a prop change,so React reused the one instance across controls: opening control B after control
A crashed showed B the stale error and never mounted B. Now keyed
key={sc.key},which makes switching controls a remount — the only thing that resets the
boundary — with a comment saying so, since the key is load-bearing and looks
incidental. A test pins the contract the key depends on (a remount clears the
boundary), so it fails if that error state ever moves somewhere a remount does not
reset.
RFC line citations rewritten as symbol names. Rebasing picked up docs: refresh prompts, skills and docs against the shipped code #8905, which
added a
docs-lintrule againstfile:linecitations in prose ("a name survivesthe refactor that moves the line") and baselined the 743 pre-existing violations.
This RFC is new, so its 13 citations were not baselined — and they proved the
rule's point: several had already drifted (
manifest.py:476whenUIConfigsitsat 597,
:422whenUIOverlaysits at 451,SessionControlHost.tsx:33whenSessionControlContextsits at 37). All 13 now name the symbol instead. Thebaseline file was deliberately NOT touched: it records violations that predate
the rule, not new ones.
Session controls now receive the session identity, not the slot id. ChatPage
passed
activeSlot(chat-2) assessionKey, where the rest of the app storessession-scoped state under
dashboard:<slot>— the derivationMobileConnectModal,ChatInput's skill slot and
workflows/runModelall use. An app would have keyedits per-session state on a string nothing else uses, which is the exact
mis-binding this feature exists to remove. Both the status poll and the control
props now use the derived key. Raised by GPT 5.6 and downgraded to advisory on
adjudication; fixed anyway because it is a real defect in the feature's central
contract, and cheap. Note the suggested
currentSlot.linked_session_keywas NOTused: that field exists only in the backend and is on no frontend type.
Escape during IME composition no longer discards a control draft (blocking,
GPT 5.6, upheld-fenced). The host's document
Escapehandler had nocomposition guard, so an IME user cancelling a candidate closed the popover and
lost an unsaved draft. It now claims through
useDocumentImeLatch, the existinghook for exactly this, and closes only when the latch accepts the key. Two tests
pin both halves: latched Escape does not close, and Escape closes again once the
composition ends.
Folder-query failures surface (blocking, GPT 5.6, upheld).
chatFoldersdiscarded its query error. On
/embed/chatno sidebar is mounted to consume theshared
['chat-folders']cache, so nothing surfaced it anywhere; it now joinsthe session-control
ErrorNoticerather than getting a banner of its own.Feature map row added (blocking, GPT 5.6, upheld).
docs/feature-map/README.mdgains an App session controls row under Apps, naming the composer entry
point, the frontend and backend owners, and both status-route prefixes. The
mechanical gate does not fire here (no page, handler or route was added), but the
map's own maintenance contract covers a PR that ADDS a feature, and this one does.
Session-control query errors reach the user (blocking, GPT 5.6, upheld).
useSessionControlsanduseSessionControlStatusescoerced their query errorsto
[]/{}, so a failed/api/appsrendered no chips and a failed statusprobe rendered a stateless one — both indistinguishable from "no app declares a
control". Each hook now returns its error alongside its data (the data still
fails closed), and
ChatPagerenders oneErrorNoticewithaskAgentabovethe composer, beside the chips it concerns. Four tests pin it, and the new
components.sessionControlHost.controls_unavailablestring is translated in all13 locales with the pseudo-locale regenerated.
Cap drift corrected to 2. The code ships
MAX_INLINE_SESSION_CONTROLS = 2;this description and RFC §5/§7 said three. Both are fixed, along with the
mangled audit-log sentence in
docs/request-for-change/README.md.Two GPT 5.6 findings are deliberately not actioned, per that review's own
adjudication, which downgraded both as
disproportionate-remedy: F1 (afeature-map row — the rule fires on added pages/handlers/routes, and explicitly
rejects rows for non-structural churn) and F3 (an
ErrorNoticeon a discardedfolder-label fetch — the established convention, self-correcting over WebSocket
reseeding, and the rule excludes empty states).