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
55 changes: 33 additions & 22 deletions docs/workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ The following sections highlight some principles in more detail.
### Repository Layout
- Experience workspace work lives under **`nx2/`** (alongside the CDN-mapped `nx/` tree).
- The nx2 scripts and utils provide URL/state helpers, auth, and block loading (`nx2/scripts/nx.js`, `nx2/utils/`).
- Feature code lives in **`nx2/blocks/`** — currently including **`canvas`** (the edit experience; not a separate edit block), **`chat`**, shell pieces (**`nav`**, **`sidenav`**, **`profile`**), **`fragment`** / **`dialog`**, and small helpers (**`action-button`**, **`canvas-actions`**). **`tool-panel`** is a placeholder block; tool UI is intended to ship inside loaded fragments.
- Feature code lives in **`nx2/blocks/`** — currently including **`canvas`** (the edit experience; not a separate edit block), **`chat`**, shell pieces (**`nav`**, **`sidenav`**, **`profile`**), **`fragment`** / **`dialog`**, and small helpers (**`action-button`**, **`canvas-actions`**).
- Shared shell behavior for app-frame side regions is implemented in **`nx2/utils/panel.js`** (DOM panel chrome, resize, show/hide, persistence), not as a separate `blocks/panel` Lit shell.
- **`blocks/tool-panel`** provides the managed panel shell (`nx-tool-panel`): a picker to switch between views, a header-actions zone for first-party views, and a close button. Consumer content is lazy-loaded on first activation. Like chat, `nx-tool-panel` is position-agnostic — host blocks instantiate it and set its `views` array inside their own `getContent`.
- **`blocks/chat`** has no public entry-point wrapper — host blocks mount `nx-chat` directly inside their own `getContent`.
- When **`blocks/shared`** (or equivalent) exists, it should contain small, reusable pieces that make no assumptions about where they are invoked from.
- Root **`Utils`** contains helpers such as the extension SDK client and DA API wrappers (`daFetch.js`: origins + `daFetch`).

Expand Down Expand Up @@ -49,7 +51,7 @@ nx2
├── blocks
│ ├── canvas (edit: nx-canvas-header, panel toggles, main editing layout)
│ ├── chat
│ ├── tool-panel (placeholder; content from fragments)
│ ├── tool-panel (managed panel shell: picker, header-actions zone, consumer lifecycle)
│ └── … (e.g. browse when added as its own block)
└── utils
Expand All @@ -65,31 +67,37 @@ Skills Lab — external app at da.live/apps/skills, linked from Chat
## Side panels (app-frame)

- Panels are **`aside.panel`** elements with **`data-position="before"`** (to the left of `main`) or **`"after"`** (to the right). Width is stored on the element; **`setPanelsGrid()`** updates CSS grid template vars on `body` when panels are visible.
- **`openPanelWithFragment`** loads markup via **`loadPanelContent`** (fragment URLs or, for legacy paths, block modules), then **`showPanel`** mounts the shell and appends content into **`.panel-body`**.
- **`hidePanel` / `unhidePanel`** toggle visibility without removing the node; hidden panels are omitted from the grid.
- **`localStorage`** key **`nx-panels`** stores `{ before?, after? }` with width and fragment URL. **`restorePanels()`** is invoked from **`loadArea`** in **`nx2/scripts/nx.js`** when that key is present so panels return across reloads.
- **`hidePanel` / `showPanel`** toggle visibility without removing the node; hidden panels are omitted from the grid. (`unhidePanel` is a legacy alias for `showPanel`.) Any consumer can close its panel by dispatching an **`nx-panel-close`** event — the panel frame handles it.
- **`localStorage`** key **`nx-panels`** stores `{ before?, after? }` with width and (for fragment-based panels) fragment URL. **`restorePanels()`** is invoked from **`loadArea`** in **`nx2/scripts/nx.js`** for fragment-based panels; page blocks (e.g. canvas) restore their own typed panels directly on load.
- **`openPanel({ position, width, getContent })`** is the single entry point for opening a panel programmatically. The caller provides a `getContent` async function that returns the element to mount in the panel body. This means the caller is fully responsible for what goes inside — whether that is a headless component like `nx-chat` or a managed shell like `nx-tool-panel`. `openPanel` handles the show/create/skip-if-visible logic and nothing else.

### Panel header and custom actions
### Two panel types

When a panel is opened via `canvas.js`, **`addPanelHeader`** prepends a header bar (`.panel-header`) to `.panel-body` and fires an **`nx-panel-slot`** event on `.panel-body`:
The distinction between panel types is a **caller convention**, not a framework concept.

```js
panelBody.dispatchEvent(new CustomEvent('nx-panel-slot', {
detail: { slot: header.querySelector('.panel-header-custom') },
}));
```
**Headless** — `getContent` returns a component that owns its entire layout: header, actions, close button. Used when a single, known first-party component permanently occupies the panel

**Managed** — `getContent` imports and instantiates `nx-tool-panel`, sets its `views` array, and returns it. `nx-tool-panel` then owns the header with a consumer picker, actions zone, and close button. Used when multiple views share a panel (e.g. tools and extensions).

### Consumer contract (managed panels)

Fragment content loaded into a panel can listen for this event in `connectedCallback` to register buttons into the header's left-side container:
Each consumer is a descriptor object passed in the `views` array:

```js
// in connectedCallback
this.closest('.panel-body')?.addEventListener('nx-panel-slot', (e) => {
this._panelSlot = e.detail.slot;
this._mountActions(); // called once both slot and icons are ready
}, { once: true });
{
id: 'my-tool', // unique string key
label: 'My Tool', // shown in the picker
firstParty: true, // omit or false for third-party
load: async () => element, // called once on first activation; must return an HTMLElement
}
```

Buttons appended to the slot should use `className = 'panel-header-action'` to pick up the shared button styling defined in `nx-panel-header.css`. The `[hidden]` attribute is respected — set `btn.hidden = true/false` to show/hide conditionally.
Each consumer registered with a managed panel declares whether it is **first-party** or not:

- **First-party views** (`firstParty: true`) are authored by the workspace team, fully trusted, and may expose header actions by implementing a `getHeaderActions()` method on the element returned by `load()`. The method should return an `HTMLElement` (or `null`/`undefined` to add nothing). It is called each time the consumer becomes active.
- **Third-party / fragment views** are not authored by the team. They receive only the content area; they cannot add anything to the header. This is the default for extensions and external fragments.

Consumer content is lazy-loaded on first activation and preserved across open/close cycles — switching views or hiding the panel does not reload content.

---

Expand All @@ -107,8 +115,9 @@ The edit experience is implemented as the **`canvas`** block — there is not a

- Owns the editing workspace: breadcrumbs, view mode (doc/wysiwyg/split), and layout state as those features land; composes editing-focused layouts around **`main`**.
- Decorates the canvas region with **`nx-canvas-header`** (Lit toolbar: e.g. split icons for panel edges, undo/redo affordances).
- Listens for **`nx-canvas-toggle-panel`** (`detail.position`: **`before`** | **`after`**) and calls **`toggleCanvasPanel`** in **`canvas.js`**: show or hide the matching **`aside.panel`**, or **`openPanelWithFragment`** with the configured fragment URL (e.g. chat before main, tool panel after main).
- Side regions use the same panel model: **`before`** / **`after`** asides can host in-context browser, history, metadata, outline, etc., loaded as fragments or blocks through **`panel.js`** — toggled from the header chrome or other entry points as the product defines.
- Listens for panel open events from **`nx-canvas-header`** and opens the matching panel via **`panel.js`**. Owns a `CANVAS_PANELS` config object keyed by position — each entry carries a default `width` and a `getContent` callback. `openCanvasPanel(position)` does a config lookup and calls `openPanel` directly; no dependency on block-specific helpers like `openChatPanel`.
- Owns the panel configuration for its context: which positions are supported and what `getContent` each panel uses. Browse or other page-level blocks define their own panel configurations independently.
- Side regions use the same panel model: **`before`** / **`after`** asides can host in-context browser, history, metadata, outline, etc., registered as views through the managed panel — toggled from the header chrome or other entry points as the product defines.
- Adopts **`canvas.css`** once on the document for light-DOM rules that apply outside the header shadow root.

### Browse Block
Expand All @@ -120,7 +129,7 @@ The edit experience is implemented as the **`canvas`** block — there is not a
- Owns conversation state, tool execution, agent communication, and context item accumulator
- Consumes workspace context (org, site, path, view) as read-only
- Runs in Browse and in the edit (`canvas`) view with the same UI; only view context sent to the backend changes
- In the app-frame experiment, the chat surface may be loaded as fragment content inside a **`before`** panel opened from the canvas header.
- Chat is position-agnostic — host blocks (canvas, browse) decide to mount it in the `before` panel via their own `getContent`. Once mounted, chat owns its full internal layout: header, context-sensitive actions, and the close control.

### Shared Block
Provides shared functionality for Browse, the edit (`canvas`) experience, and Chat. Examples, non-exhaustive:
Expand Down Expand Up @@ -157,4 +166,6 @@ Chat receives host-pushed context (URL-derived workspace state + accumulated ite

**Configured extensions** — we did not author them. Absent by default, sandboxed, minimal contract only.

In the panel system this maps directly: core features are first-party views and may add actions to the managed panel header; configured extensions are third-party views and receive only the content area.

The API for third-party extensions is defined by the extensions SDK.
37 changes: 37 additions & 0 deletions nx2/blocks/browse/browse.css
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,44 @@
min-width: 0;
}

.browse-bar {
display: flex;
align-items: center;
flex-shrink: 0;
height: 40px;
padding: 0 var(--s2-spacing-100);

path {
fill: currentcolor;
}
}

.browse-panel-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: var(--s2-corner-radius-400);
background: transparent;
color: var(--s2-gray-800);
cursor: pointer;

svg {
display: block;
width: 16px;
height: 16px;
flex-shrink: 0;
}

&:hover {
background-color: var(--s2-gray-75);
}
}

.browse-hint {
margin: 0 var(--s2-spacing-300);
padding: var(--s2-spacing-200);
border-radius: var(--s2-corner-radius-300);
background-color: var(--s2-gray-75);
Expand Down
51 changes: 49 additions & 2 deletions nx2/blocks/browse/browse.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { LitElement, html, nothing } from 'da-lit';
import { loadStyle, hashChange } from '../../utils/utils.js';
import { loadHrefSvg, ICONS_BASE } from '../../utils/svg.js';
import { getPanelStore, openPanel } from '../../utils/panel.js';
import { listFolder } from './browse-api.js';
import {
contextToPathContext,
Expand All @@ -11,6 +13,7 @@ import '../shared/breadcrumb/breadcrumb.js';
import './list/list.js';

const styles = await loadStyle(import.meta.url);
const panelIcon = await loadHrefSvg(`${ICONS_BASE}S2_Icon_SplitLeft_20_N.svg`);

const documentLayoutStyles = await loadStyle(
new URL('overrides.css', import.meta.url).href,
Expand All @@ -32,6 +35,14 @@ class NxBrowse extends LitElement {
}
}

_openPanel(position) {
this.dispatchEvent(new CustomEvent('nx-browse-open-panel', {
bubbles: true,
composed: true,
detail: { position },
}));
}

connectedCallback() {
super.connectedCallback();
this.shadowRoot.adoptedStyleSheets = [styles];
Expand Down Expand Up @@ -103,8 +114,21 @@ class NxBrowse extends LitElement {
render() {
const ctx = this._pathContext;

const bar = html`
<div class="browse-bar">
<button
type="button"
part="toggle-before"
class="browse-panel-toggle"
aria-label="Open panel"
@click=${() => this._openPanel('before')}
>${panelIcon ?? nothing}</button>
</div>
`;

if (!ctx) {
return html`
${bar}
<div class="browse-hint" role="status">
<p class="browse-hint-title">Nothing to show here yet</p>
<p class="browse-hint-detail">
Expand All @@ -117,7 +141,7 @@ class NxBrowse extends LitElement {
const title = ctx.pathSegments.at(-1) ?? '';

if (!this._listError && this._items === undefined) {
return nothing;
return bar;
}

const header = html`
Expand All @@ -131,6 +155,7 @@ class NxBrowse extends LitElement {

if (this._listError) {
return html`
${bar}
${header}
<div class="browse-hint browse-hint-error" role="alert">
<p class="browse-hint-title">Could not load this folder</p>
Expand All @@ -142,6 +167,7 @@ class NxBrowse extends LitElement {
const currentPathKey = ctx.pathSegments.join('/');

return html`
${bar}
${header}
<nx-browse-list
.items=${this._items}
Expand All @@ -158,5 +184,26 @@ if (!customElements.get('nx-browse')) {

export default function decorate(block) {
block.textContent = '';
block.append(document.createElement('nx-browse'));
const browse = document.createElement('nx-browse');
block.append(browse);

const openBrowseChatPanel = () => {
const store = getPanelStore();
const width = store.before?.width ?? '400px';
openPanel({
position: 'before',
width,
getContent: async () => {
await import('../chat/chat.js');
return document.createElement('nx-chat');
},
});
};

browse.addEventListener('nx-browse-open-panel', (e) => {
if (e.detail.position === 'before') openBrowseChatPanel();
});

const store = getPanelStore();
if (store.before) openBrowseChatPanel();
}
6 changes: 6 additions & 0 deletions nx2/blocks/browse/overrides.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,9 @@ main:has(nx-browse) .browse {
overflow: hidden;
min-height: 0;
}

/* Hide the toggle when the before panel is already visible */
html:has(aside.panel[data-position="before"]:not([hidden]))
nx-browse::part(toggle-before) {
display: none;
}
16 changes: 0 additions & 16 deletions nx2/blocks/canvas/canvas.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,6 @@ html:has(aside.panel[data-position="after"]:not([hidden])) nx-canvas-header::par
display: none;
}

.fragment-content:has(nx-chat) {
height: 100%;

& .section {
height: 100%;
}

& .block-content {
height: 100%;
}
}

nx-chat {
height: 100%;
}

/* Single visible editor: Layout (doc) or Content (WYSIWYG); inactive uses [hidden] */
.nx-canvas-editor-mount {
display: flex;
Expand Down
74 changes: 28 additions & 46 deletions nx2/blocks/canvas/canvas.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { loadStyle, hashChange } from '../../utils/utils.js';
import { hidePanel, unhidePanel, openPanelWithFragment } from '../../utils/panel.js';
import { getPanelStore, openPanel } from '../../utils/panel.js';
import './nx-canvas-header/nx-canvas-header.js';
import './nx-editor-doc/nx-editor-doc.js';
import './nx-editor-wysiwyg/nx-editor-wysiwyg.js';
Expand All @@ -13,11 +13,6 @@ function buildCanvasDocPath(state) {
return `${org}/${site}/${path}`;
}

const FRAGMENTS = {
before: 'https://da.live/fragments/exp-workspace/chat',
after: 'https://da.live/fragments/exp-workspace/tool',
};

const CANVAS_EDITOR_VIEW_KEY = 'nx-canvas-editor-view';

function normalizeCanvasEditorView(view) {
Expand Down Expand Up @@ -97,41 +92,31 @@ function syncCanvasEditorsToHash({ mountRoot, header, state }) {
notifyCanvasEditorActive(mountRoot, header.editorView);
}

async function addPanelHeader(aside) {
const { default: createPanelHeader } = await import('./nx-panel-header/nx-panel-header.js');
const header = await createPanelHeader({
position: aside.dataset.position,
onClose: () => hidePanel(aside),
});
const panelBody = aside.querySelector('.panel-body');
panelBody.prepend(header);

// to enable adding actions to the header
panelBody.dispatchEvent(new CustomEvent('nx-panel-slot', {
detail: { slot: header.querySelector('.panel-header-custom') },
}));
}

async function openCanvasPanel(position) {
// Case 1: Panel is visible
const existing = document.querySelector(`aside.panel[data-position="${position}"]`);
if (existing && !existing.hidden) return;

// Case 2: Panel is hidden
if (existing?.hidden) {
unhidePanel(existing);
return;
}

// Case 3: Panel does not exist yet
const aside = await openPanelWithFragment({
const CANVAS_PANELS = {
before: {
width: '400px',
beforeMain: position === 'before',
fragment: FRAGMENTS[position],
});
getContent: async () => {
await import('../chat/chat.js');
return document.createElement('nx-chat');
},
},
after: {
width: '400px',
getContent: async () => {
await import('../tool-panel/tool-panel.js');
const toolPanel = document.createElement('nx-tool-panel');
toolPanel.views = [];
return toolPanel;
},
},
};

// Add header to panel after crating
addPanelHeader(aside);
async function openCanvasPanel(position) {
const config = CANVAS_PANELS[position];
if (!config) return;
const store = getPanelStore();
const width = store[position]?.width ?? config.width;
await openPanel({ position, width, getContent: config.getContent });
}

function installCanvasHeader(block) {
Expand Down Expand Up @@ -159,11 +144,8 @@ export default async function decorate(block) {
syncCanvasEditorsToHash({ mountRoot, header, state });
});

document.addEventListener('nx-panels-restored', () => {
document.querySelectorAll('aside.panel').forEach((aside) => {
if (FRAGMENTS[aside.dataset.position] === aside.dataset.fragment) {
addPanelHeader(aside);
}
});
});
// Restore any panels that were open in the previous session.
const store = getPanelStore();
if (store.before) openCanvasPanel('before');
if (store.after) openCanvasPanel('after');
}
Loading
Loading