feat(ai-sessions): share session artifacts with the workspace by link

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pyjp67oR269QAx3b4yf4oH
This commit is contained in:
Ruben Fiszel
2026-09-14 16:22:19 +02:00
co-authored by Claude Opus 5
parent c90d1d95c2
commit b074f73974
20 changed files with 862 additions and 66 deletions
@@ -0,0 +1 @@
DROP TABLE IF EXISTS ai_shared_artifact;
@@ -0,0 +1,32 @@
-- A copy of an AI session artifact that its author explicitly shared with the workspace.
-- Artifacts otherwise live only in the author's browser; this row exists only while the
-- share does, and the monitor deletes it once `shared_at` falls outside
-- AI_SHARED_ARTIFACT_RETENTION_SECS.
CREATE TABLE ai_shared_artifact (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
-- The browser-side artifact id. Unique per author so sharing the same artifact again
-- moves its one link forward rather than minting a second one.
artifact_id VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
created_by VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
kind VARCHAR(10) NOT NULL CHECK (kind IN ('md', 'html')),
version INTEGER NOT NULL,
content TEXT NOT NULL,
-- Reset on every re-share: retention counts from the last time the author shared it.
shared_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (workspace_id, email, artifact_id)
);
CREATE INDEX idx_ai_shared_artifact_shared_at ON ai_shared_artifact (shared_at);
GRANT ALL ON ai_shared_artifact TO windmill_admin;
GRANT ALL ON ai_shared_artifact TO windmill_user;
-- The handlers go through the raw pool and scope every query to the workspace themselves.
-- An admin-only policy is the backstop for a future query that reaches this table through
-- UserDB.
ALTER TABLE ai_shared_artifact ENABLE ROW LEVEL SECURITY;
CREATE POLICY admin_policy ON ai_shared_artifact FOR ALL TO windmill_admin USING (true);
+11
View File
@@ -1882,6 +1882,17 @@ pub async fn delete_expired_items(db: &DB) -> () {
tracing::info!("deleted {} expired otel trace spans", deleted_spans);
}
if let Err(e) = sqlx::query!(
"DELETE FROM ai_shared_artifact
WHERE shared_at <= now() - ($1::bigint::text || ' s')::interval",
windmill_common::ai_shared_artifact_retention_secs(),
)
.execute(db)
.await
{
tracing::error!("Error deleting expired shared AI artifacts: {:?}", e);
}
let audit_retention_days = audit_log_retention_days().await;
let audit_retention_secs: i64 = audit_retention_days * 60 * 60 * 24;
+2
View File
@@ -40,6 +40,8 @@ agent_token_blacklist: token(char), expires_at(ts), blacklisted_at(ts), blacklis
ai_agent_memory: workspace_id(char), conversation_id(uuid), step_id(char), messages(jsonb), created_at(ts), updated_at(ts)
ai_free_token_daily_usage: day(date), cost_nanos(bigint), updated_at(ts)
ai_free_token_usage: email(char), cost_nanos(bigint), updated_at(ts)
ai_shared_artifact: id(uuid), workspace_id(char), artifact_id(char), email(char), created_by(char), name(char), kind(char), version(int), content(text), shared_at(ts)
FK: (workspace_id) -> workspace(id)
ai_token_usage: workspace_id(char), day(date), email(char), provider(char), model(char), session_id(char), input_tokens(bigint), cache_read_tokens(bigint), cache_write_tokens(bigint), output_tokens(bigint), reported_cost_nano_usd(bigint), requests(bigint), updated_at(ts)
FK: (workspace_id) -> workspace(id)
alerts: id(int), alert_type(char), message(text), created_at(ts), acknowledged(bool), workspace_id(text), acknowledged_workspace(bool), resource(text)
+127
View File
@@ -0,0 +1,127 @@
//! Shared AI session artifacts: one link per author and artifact, readable by any workspace
//! member until its retention window passes, and removable only by its author or an admin.
//!
//! Expiry is enforced on read as well as by the monitor's sweep, so a share past its window must
//! not be served in the gap before the sweep reaches it.
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const ADMIN: &str = "Bearer SECRET_TOKEN";
const MEMBER: &str = "Bearer SECRET_TOKEN_2";
async fn share(
client: &reqwest::Client,
base: &str,
token: &str,
content: &str,
) -> anyhow::Result<Value> {
let resp = client
.post(format!("{base}/share"))
.header("Authorization", token)
.json(&json!({
"artifact_id": "plan:session-1",
"name": "Plan",
"kind": "md",
"version": 1,
"content": content,
}))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
Ok(resp.json().await?)
}
#[sqlx::test(fixtures("base"))]
async fn shared_artifact_is_served_to_members_until_it_expires(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!(
"http://localhost:{}/api/w/test-workspace/ai/shared_artifacts",
server.addr.port()
);
let client = reqwest::Client::new();
let first = share(&client, &base, MEMBER, "draft").await?;
let second = share(&client, &base, MEMBER, "final").await?;
assert_eq!(first["id"], second["id"], "re-sharing minted a second link");
let id = second["id"].as_str().unwrap();
let resp = client
.get(format!("{base}/get/{id}"))
.header("Authorization", ADMIN)
.send()
.await?;
assert_eq!(resp.status(), 200);
let body: Value = resp.json().await?;
assert_eq!(body["content"], "final");
sqlx::query(
"UPDATE ai_shared_artifact SET shared_at = now() - ($1::bigint + 60) * interval '1 second'",
)
.bind(windmill_common::ai_shared_artifact_retention_secs())
.execute(&db)
.await?;
let resp = client
.get(format!("{base}/get/{id}"))
.header("Authorization", ADMIN)
.send()
.await?;
assert_eq!(resp.status(), 404, "an expired share was served");
let status: Value = client
.get(format!("{base}/status?artifact_id=plan:session-1"))
.header("Authorization", MEMBER)
.send()
.await?
.json()
.await?;
assert!(
status.get("share").is_none(),
"an expired share was reported live: {status}"
);
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn only_the_author_or_an_admin_can_unshare(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!(
"http://localhost:{}/api/w/test-workspace/ai/shared_artifacts",
server.addr.port()
);
let client = reqwest::Client::new();
let shared = share(&client, &base, ADMIN, "admin's plan").await?;
let id = shared["id"].as_str().unwrap();
let resp = client
.delete(format!("{base}/delete/{id}"))
.header("Authorization", MEMBER)
.send()
.await?;
assert_eq!(resp.status(), 404);
let remaining: i64 = sqlx::query_scalar("SELECT count(*) FROM ai_shared_artifact")
.fetch_one(&db)
.await?;
assert_eq!(remaining, 1, "a member deleted someone else's share");
let member_share = share(&client, &base, MEMBER, "member's plan").await?;
let resp = client
.delete(format!(
"{base}/delete/{}",
member_share["id"].as_str().unwrap()
))
.header("Authorization", ADMIN)
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
Ok(())
}
+158
View File
@@ -12896,6 +12896,134 @@ paths:
type: boolean
description: more buckets matched than were returned, so summing them under-reports
/w/{workspace}/ai/shared_artifacts/share:
post:
summary: share an AI session artifact with the workspace
description: >
Stores a read-only copy that any member of the workspace can open by id. Sharing the
same artifact again updates that copy, keeps its id, and restarts its retention window.
operationId: shareAiArtifact
tags:
- ai
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- artifact_id
- name
- kind
- version
- content
properties:
artifact_id:
type: string
description: the artifact's id in the author's session
name:
type: string
kind:
type: string
enum: [md, html]
version:
type: integer
minimum: 1
content:
type: string
responses:
"200":
description: the shared copy
content:
application/json:
schema:
$ref: "#/components/schemas/SharedAiArtifactInfo"
/w/{workspace}/ai/shared_artifacts/status:
get:
summary: get the calling user's share of one of their AI session artifacts
operationId: getAiArtifactShareStatus
tags:
- ai
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: artifact_id
in: query
required: true
schema:
type: string
responses:
"200":
description: the live share, if any, and how long shares last
content:
application/json:
schema:
type: object
required:
- retention_secs
properties:
retention_secs:
type: integer
share:
$ref: "#/components/schemas/SharedAiArtifactInfo"
/w/{workspace}/ai/shared_artifacts/get/{id}:
get:
summary: get a shared AI session artifact
operationId: getSharedAiArtifact
tags:
- ai
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: the shared artifact
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/SharedAiArtifactInfo"
- type: object
required:
- content
- can_unshare
properties:
content:
type: string
can_unshare:
type: boolean
description: whether the caller authored the share or is a workspace admin
/w/{workspace}/ai/shared_artifacts/delete/{id}:
delete:
summary: stop sharing an AI session artifact
operationId: unshareAiArtifact
tags:
- ai
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: share deleted
content:
text/plain:
schema:
type: string
/w/{workspace}/apps/get_data/v/{secretWithExtension}:
get:
summary: get raw app data by
@@ -27961,6 +28089,36 @@ components:
- output_tokens
- requests
SharedAiArtifactInfo:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
kind:
type: string
enum: [md, html]
version:
type: integer
created_by:
type: string
shared_at:
type: string
format: date-time
expires_at:
type: string
format: date-time
required:
- id
- name
- kind
- version
- created_by
- shared_at
- expires_at
InstanceAIProviderSummary:
type: object
properties:
+4
View File
@@ -518,6 +518,10 @@ pub fn workspaced_service() -> Router {
// could make the server allocate and parse an arbitrarily large one.
// Sized well above a full batch of the shape below.
.layer(DefaultBodyLimit::max(AI_USAGE_BODY_LIMIT)),
)
.nest(
"/shared_artifacts",
crate::ai_shared_artifacts::workspaced_service(),
);
#[cfg(feature = "bedrock")]
Binary file not shown.
+1
View File
@@ -69,6 +69,7 @@ mod ai;
#[cfg(feature = "private")]
mod ai_free_tier_ee;
mod ai_free_tier_oss;
mod ai_shared_artifacts;
mod apps;
mod apps_raw_bundle;
pub use apps::invalidate_app_policy_cache;
@@ -457,6 +457,7 @@ pub const ENV_SETTINGS: &[&str] = &[
"OTEL_RESOURCE_ATTRIBUTES",
"OTEL_JOB_LOGS",
"OTEL_TRACES_RETENTION_SECS",
"AI_SHARED_ARTIFACT_RETENTION_SECS",
"DISABLE_S3_STORE",
"PG_SCHEMA",
"PG_LISTENER_REFRESH_PERIOD_SECS",
+17
View File
@@ -151,6 +151,7 @@ pub const DEFAULT_HUB_BASE_URL: &str = "https://hub.windmill.dev";
pub const PRIVATE_HUB_MIN_VERSION: i32 = 10_000_000;
pub const DEFAULT_SERVICE_LOG_RETENTION_SECS: i64 = 60 * 60 * 24 * 14; // 2 weeks retention period for logs
pub const DEFAULT_OTEL_TRACES_RETENTION_SECS: i64 = 60 * 60 * 24 * 7; // 1 week retention period for HTTP request spans
pub const DEFAULT_AI_SHARED_ARTIFACT_RETENTION_SECS: i64 = 60 * 60 * 24 * 30;
pub const WM_DEPLOYERS_GROUP: &str = "wm_deployers";
/// A century. Every consumer has to survive `now - retention`, and the ceilings are much lower
@@ -229,6 +230,13 @@ pub fn service_log_retention_secs() -> i64 {
SERVICE_LOG_RETENTION_SECS.load(std::sync::atomic::Ordering::Relaxed)
}
/// How long a shared AI session artifact stays viewable, in seconds, counted from the last time
/// its author shared it. Read by both the API, which stops serving an expired share, and the
/// monitor, which deletes it — so both must agree, which is why they share this one reader.
pub fn ai_shared_artifact_retention_secs() -> i64 {
*AI_SHARED_ARTIFACT_RETENTION_SECS
}
/// Canonical form of a base URL, used as one of the inputs to the offline-license
/// instance hash (`compute_instance_hash`).
///
@@ -476,6 +484,15 @@ lazy_static::lazy_static! {
/// [`set_otel_traces_retention_secs`] is the only writer, [`otel_traces_retention_secs`] the
/// only reader.
static ref OTEL_TRACES_RETENTION_SECS: AtomicI64 = AtomicI64::new(DEFAULT_OTEL_TRACES_RETENTION_SECS);
/// Read it with [`ai_shared_artifact_retention_secs`].
static ref AI_SHARED_ARTIFACT_RETENTION_SECS: i64 = clamp_retention_secs(
std::env::var("AI_SHARED_ARTIFACT_RETENTION_SECS")
.ok()
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(DEFAULT_AI_SHARED_ARTIFACT_RETENTION_SECS),
DEFAULT_AI_SHARED_ARTIFACT_RETENTION_SECS,
"AI shared artifact",
);
pub static ref MONITOR_LOGS_ON_OBJECT_STORE: AtomicBool = AtomicBool::new(false);
@@ -45,6 +45,21 @@
const previewAction = $derived(available?.type === 'open_item_preview' ? available : undefined)
const drawerAction = $derived(available?.type === 'open_created_resource' ? available : undefined)
// The markdown is authored by a model, or by another member for a shared artifact, and the
// renderer passes `javascript:` and `data:` hrefs through untouched. Relative links resolve
// against this page, so they stay.
const SAFE_PROTOCOLS = ['http:', 'https:', 'mailto:']
const safeHref = $derived.by(() => {
if (!href) return undefined
try {
return SAFE_PROTOCOLS.includes(new URL(href, window.location.href).protocol)
? href
: undefined
} catch {
return undefined
}
})
const modifier = newTabModifier()
const hint = $derived(
@@ -68,7 +83,7 @@
}
</script>
{#if href}
{#if safeHref}
{#if wmKind}
<!-- Only a preview pill can change icon, so only it is worth tracking the modifier for. -->
<span
@@ -76,7 +91,7 @@
{@attach previewAction ? modifier.attach : undefined}
>
<a
{href}
href={safeHref}
target={previewAction ? undefined : '_blank'}
rel={previewAction ? undefined : 'noopener noreferrer'}
title={title || hint}
@@ -119,8 +134,10 @@
{/if}
</span>
{:else}
<a {href} target="_blank" rel="noopener noreferrer" {title}>
<a href={safeHref} target="_blank" rel="noopener noreferrer" {title}>
{@render children?.()}
</a>
{/if}
{:else if href}
{@render children?.()}
{/if}
@@ -0,0 +1,34 @@
<script lang="ts">
import Markdown from 'svelte-exmarkdown'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import { markdownProse } from '$lib/components/markdownProse'
import CodeDisplay from '../script/CodeDisplay.svelte'
import LinkRenderer from '../LinkRenderer.svelte'
interface Props {
content: string
/** The raw text rather than the rendered document. */
source: boolean
}
let { content, source }: Props = $props()
const plugins = [gfmPlugin(), { renderer: { pre: CodeDisplay, a: LinkRenderer } }]
</script>
<!-- Rendered inside the caller's scroll container: the fade below is sticky to it. -->
{#if source}
<!-- key: SimpleEditor reads `code` only on init. -->
{#key content}
<SimpleEditor lang="markdown" code={content} readOnly class="h-full" />
{/key}
{:else}
<!-- Pinned under the header, fades scrolled-under content instead of hard-clipping it.
The negative margin cancels its flow height so it overlays instead of pushing. -->
<div class="sticky top-0 z-10 h-4 -mb-4 bg-gradient-to-b from-surface-tertiary to-transparent"
></div>
<div class="pb-4 pt-2 {markdownProse.doc}">
<Markdown md={content} {plugins} />
</div>
{/if}
@@ -0,0 +1,42 @@
<script lang="ts">
import { Check, Copy, Download } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import { copyToClipboard, download } from '$lib/utils'
import { artifactFilename, artifactMimeType, type ArtifactKind } from './artifactsDB'
interface Props {
name: string
kind: ArtifactKind
content: string
disabled?: boolean
}
let { name, kind, content, disabled = false }: Props = $props()
let copied = $state(false)
async function copyRaw() {
if (!(await copyToClipboard(content))) return
copied = true
setTimeout(() => (copied = false), 1500)
}
</script>
<!-- A Button's `disabled` does not reach its dropdown items, so the item carries its own. -->
<Button
unifiedSize="sm"
variant="default"
{disabled}
startIcon={{ icon: copied ? Check : Copy }}
onClick={copyRaw}
title="Copy raw markdown"
dropdownItems={[
{
label: 'Download as .md',
icon: Download,
onClick: () => download(artifactFilename({ name, kind }), content, artifactMimeType(kind)),
disabled
}
]}
>
{copied ? 'Copied' : 'Copy'}
</Button>
@@ -0,0 +1,179 @@
<script lang="ts">
import { Link, Share2 } from 'lucide-svelte'
import { resource } from 'runed'
import { Button } from '$lib/components/common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
import { AiService, type SharedAiArtifactInfo } from '$lib/gen'
import { userWorkspaces, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { copyToClipboard, displayDate } from '$lib/utils'
import type { ArtifactKind } from './artifactsDB'
import { formatRetention, sharedArtifactUrl, shareWorkspaceId } from './artifactSharing'
interface Props {
artifactId: string
name: string
kind: ArtifactKind
content: string
/** The version on screen, which is the one a share copies. */
version: number
disabled?: boolean
}
let { artifactId, name, kind, content, version, disabled = false }: Props = $props()
const workspace = $derived(
$workspaceStore ? shareWorkspaceId($workspaceStore, $userWorkspaces) : undefined
)
const status = resource(
() => ({ workspace, artifactId }),
async ({ workspace, artifactId }) => {
if (!workspace) return undefined
try {
return await AiService.getAiArtifactShareStatus({ workspace, artifactId })
} catch (err) {
// The button still works without it: sharing reports its own failure.
console.error('Could not read the artifact share status', err)
return undefined
}
}
)
const share = $derived(status.current?.share)
const url = $derived(share && workspace ? sharedArtifactUrl(workspace, share.id) : undefined)
// A rename earns no version, so the name is compared too.
const outdated = $derived(
share !== undefined && (share.version !== version || share.name !== name.trim())
)
let saving = $state(false)
function errorMessage(err: unknown): string {
const body = (err as { body?: unknown })?.body
return typeof body === 'string' && body ? body : String(err)
}
function reflect(next: SharedAiArtifactInfo | undefined) {
if (!status.current) return
status.mutate({ ...status.current, share: next })
}
async function shareVersion() {
if (!workspace) return
const updating = share !== undefined
saving = true
try {
const shared = await AiService.shareAiArtifact({
workspace,
requestBody: { artifact_id: artifactId, name, kind, version, content }
})
if (status.current) reflect(shared)
else await status.refetch()
if (updating) {
sendUserToast(`The link now shows v${shared.version}`)
} else if (await copyToClipboard(sharedArtifactUrl(workspace, shared.id), false)) {
sendUserToast('Link copied to clipboard')
}
} catch (err) {
sendUserToast(`Could not share the artifact: ${errorMessage(err)}`, true)
} finally {
saving = false
}
}
async function stopSharing() {
if (!workspace || !share) return
saving = true
try {
await AiService.unshareAiArtifact({ workspace, id: share.id })
reflect(undefined)
sendUserToast('Stopped sharing: the link no longer opens')
} catch (err) {
sendUserToast(`Could not stop sharing: ${errorMessage(err)}`, true)
} finally {
saving = false
}
}
</script>
<Popover
placement="bottom-end"
contentClasses="!bg-surface"
{disabled}
triggerAttrs={{ 'aria-label': 'Share artifact', 'aria-haspopup': 'dialog' }}
>
{#snippet trigger()}
<Button
unifiedSize="sm"
variant="default"
nonCaptureEvent
disabled={disabled || !workspace}
startIcon={{ icon: share ? Link : Share2 }}
title={share ? 'Shared with the workspace' : 'Share with the workspace'}
>
{share ? 'Shared' : 'Share'}
</Button>
{/snippet}
{#snippet content()}
<div class="flex w-80 flex-col gap-3 p-3 text-xs">
<div class="flex flex-col gap-1">
<span class="font-semibold text-emphasis">Share with workspace</span>
<span class="font-normal text-secondary">
{#if share}
Members of {workspace} can open a read-only copy of v{share.version} with this link.
{:else}
Members of {workspace} will be able to open a read-only copy of v{version} with a link.
{/if}
{#if status.current}
The copy is deleted {formatRetention(status.current.retention_secs)} after it is shared.
{/if}
</span>
</div>
{#if share && url}
<div class="flex flex-col gap-1">
<ClipboardPanel content={url} size="sm" />
<span class="text-2xs font-normal text-hint">
Expires {displayDate(share.expires_at)}
</span>
</div>
{#if outdated}
<div class="flex items-center justify-between gap-2">
<span class="font-normal text-secondary">
The link shows v{share.version}{share.name !== name.trim() ? ` (${share.name})` : ''}.
</span>
<Button unifiedSize="sm" variant="accent" loading={saving} onClick={shareVersion}>
Update to v{version}
</Button>
</div>
{/if}
<div class="flex justify-end">
<Button
unifiedSize="sm"
variant="default"
destructive
disabled={saving}
onClick={stopSharing}
>
Stop sharing
</Button>
</div>
{:else}
<div class="flex justify-end">
<Button
unifiedSize="sm"
variant="accent"
startIcon={{ icon: Link }}
loading={saving}
disabled={status.loading}
onClick={shareVersion}
>
Create link
</Button>
</div>
{/if}
</div>
{/snippet}
</Popover>
@@ -1,23 +1,13 @@
<script lang="ts">
import { untrack } from 'svelte'
import Markdown from 'svelte-exmarkdown'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import { Code, Eye, FileText, Copy, Check, Download, ClipboardList } from 'lucide-svelte'
import { Code, Eye, FileText, ClipboardList } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import { copyToClipboard, download } from '$lib/utils'
import CodeDisplay from '../script/CodeDisplay.svelte'
import LinkRenderer from '../LinkRenderer.svelte'
import {
artifactFilename,
artifactMimeType,
currentVersion,
type ArtifactVersion,
type PersistedArtifact
} from './artifactsDB'
import { markdownProse } from '$lib/components/markdownProse'
import { currentVersion, type ArtifactVersion, type PersistedArtifact } from './artifactsDB'
import ArtifactBody from './ArtifactBody.svelte'
import ArtifactExportButton from './ArtifactExportButton.svelte'
import ArtifactShareButton from './ArtifactShareButton.svelte'
import ArtifactVersionPicker from './ArtifactVersionPicker.svelte'
import type { SessionArtifactsStore } from './artifactsState.svelte'
import { History } from 'lucide-svelte'
@@ -114,22 +104,6 @@
const backTo = $derived(view.backToPlan)
let showSource = $state(false)
const source = $derived(!canPreview || showSource)
let copied = $state(false)
async function copyRaw() {
if (!(await copyToClipboard(shown.content))) return
copied = true
setTimeout(() => (copied = false), 1500)
}
function downloadFile() {
download(
artifactFilename({ name: shown.name, kind: artifact.kind }),
shown.content,
artifactMimeType(artifact.kind)
)
}
const plugins = [gfmPlugin(), { renderer: { pre: CodeDisplay, a: LinkRenderer } }]
</script>
<div class="flex flex-col h-full bg-surface-tertiary">
@@ -169,27 +143,22 @@
{/if}
</div>
<div class="flex items-center gap-2 shrink-0">
<!-- Copy raw markdown, with a dropdown for the download-as-file variant. Both export
`shown`, which is still the current document while a pin is restoring — so both
are disabled, the item explicitly: a Button's `disabled` does not reach it. -->
<Button
unifiedSize="sm"
variant="default"
<!-- Both export and share `shown`, which is still the current document while a pin is
restoring, so both wait for it. -->
<ArtifactShareButton
artifactId={artifact.id}
name={shown.name}
kind={artifact.kind}
content={shown.content}
version={shownVersion ?? latest}
disabled={restoringPin}
startIcon={{ icon: copied ? Check : Copy }}
onClick={copyRaw}
title="Copy raw markdown"
dropdownItems={[
{
label: 'Download as .md',
icon: Download,
onClick: downloadFile,
disabled: restoringPin
}
]}
>
{copied ? 'Copied' : 'Copy'}
</Button>
/>
<ArtifactExportButton
name={shown.name}
kind={artifact.kind}
content={shown.content}
disabled={restoringPin}
/>
{#if canPreview}
<ToggleButtonGroup
noWFull
@@ -269,19 +238,8 @@
<div class="flex-1 min-h-0 overflow-auto px-8">
{#if restoringPin}
<!-- Deliberately blank until the pinned snapshot lands; see restoringPin. -->
{:else if source}
<!-- key: SimpleEditor reads `code` only on init; remount on id or content change. -->
{#key `${artifact.id}:${pinnedContent ? `v${pinnedContent.version}` : artifact.updatedAt}`}
<SimpleEditor lang="markdown" code={shown.content} readOnly class="h-full" />
{/key}
{:else}
<!-- Pinned under the header, fades scrolled-under content instead of hard-clipping it.
The negative margin cancels its flow height so it overlays instead of pushing. -->
<div class="sticky top-0 z-10 h-4 -mb-4 bg-gradient-to-b from-surface-tertiary to-transparent"
></div>
<div class="pb-4 pt-2 {markdownProse.doc}">
<Markdown md={shown.content} {plugins} />
</div>
<ArtifactBody content={shown.content} {source} />
{/if}
</div>
</div>
@@ -0,0 +1,21 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('$lib/base', () => ({ base: '' }))
import { shareWorkspaceId } from './artifactSharing'
describe('shareWorkspaceId', () => {
it('shares a fork session into the topmost workspace the user still belongs to', () => {
const workspaces = [
{ id: 'prod' },
{ id: 'wm-fork-a', parent_workspace_id: 'prod' },
{ id: 'wm-fork-b', parent_workspace_id: 'wm-fork-a' }
]
expect(shareWorkspaceId('wm-fork-b', workspaces)).toBe('prod')
})
it('stops below a parent the user is not a member of', () => {
const workspaces = [{ id: 'wm-fork-a', parent_workspace_id: 'prod' }]
expect(shareWorkspaceId('wm-fork-a', workspaces)).toBe('wm-fork-a')
})
})
@@ -0,0 +1,42 @@
import { base } from '$lib/base'
/**
* The workspace a share from `workspaceId` lands in: the topmost ancestor the user still
* belongs to. A session often runs in a fork, whose members are its creator alone, so a link
* minted there would reach nobody — and it would be deleted with the fork.
*/
export function shareWorkspaceId(
workspaceId: string,
workspaces: { id: string; parent_workspace_id?: string | null }[]
): string {
let current = workspaceId
const seen = new Set([current])
for (;;) {
const parent = workspaces.find((w) => w.id === current)?.parent_workspace_id
if (!parent || seen.has(parent) || !workspaces.some((w) => w.id === parent)) return current
seen.add(parent)
current = parent
}
}
export function sharedArtifactUrl(workspaceId: string, shareId: string): string {
return `${window.location.origin}${base}/shared_artifacts/${encodeURIComponent(
shareId
)}?workspace=${encodeURIComponent(workspaceId)}`
}
/** "30 days", "12 hours": the retention window in the largest whole unit it fills. */
export function formatRetention(secs: number): string {
const units: [string, number][] = [
['day', 86400],
['hour', 3600],
['minute', 60]
]
for (const [unit, size] of units) {
if (secs >= size) {
const n = Math.floor(secs / size)
return `${n} ${unit}${n === 1 ? '' : 's'}`
}
}
return `${secs} second${secs === 1 ? '' : 's'}`
}
@@ -0,0 +1,144 @@
<script lang="ts">
import { page } from '$app/state'
import { resource } from 'runed'
import { Code, Eye, FileText, Link2Off } from 'lucide-svelte'
import { Button, EmptyState, Skeleton } from '$lib/components/common'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ArtifactBody from '$lib/components/copilot/chat/artifacts/ArtifactBody.svelte'
import ArtifactExportButton from '$lib/components/copilot/chat/artifacts/ArtifactExportButton.svelte'
import { AiService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { displayDate } from '$lib/utils'
const id = $derived(page.params.id ?? '')
// The link carries its workspace; the store only catches up once the layout applies it.
const workspace = $derived(page.url.searchParams.get('workspace') ?? $workspaceStore)
type Loaded =
| { state: 'found'; artifact: Awaited<ReturnType<typeof AiService.getSharedAiArtifact>> }
| { state: 'gone' }
| { state: 'error'; message: string }
const shared = resource(
() => ({ workspace, id }),
async ({ workspace, id }): Promise<Loaded | undefined> => {
if (!workspace || !id) return undefined
try {
return {
state: 'found',
artifact: await AiService.getSharedAiArtifact({ workspace, id })
}
} catch (err) {
const status = (err as { status?: number })?.status
// A malformed id is a 400 from the path extractor, and means the same to the reader.
if (status === 404 || status === 400) return { state: 'gone' }
return { state: 'error', message: String((err as { body?: unknown })?.body ?? err) }
}
}
)
const artifact = $derived(shared.current?.state === 'found' ? shared.current.artifact : undefined)
let showSource = $state(false)
const source = $derived(artifact?.kind !== 'md' || showSource)
let removing = $state(false)
async function stopSharing() {
if (!workspace || !artifact) return
removing = true
try {
await AiService.unshareAiArtifact({ workspace, id: artifact.id })
shared.mutate({ state: 'gone' })
sendUserToast('Stopped sharing: the link no longer opens')
} catch (err) {
sendUserToast(`Could not stop sharing: ${(err as { body?: unknown })?.body ?? err}`, true)
} finally {
removing = false
}
}
</script>
<div class="flex h-full flex-col px-4 py-6 sm:px-8">
<div class="mx-auto flex h-full min-h-0 w-full max-w-4xl flex-col gap-3">
{#if artifact}
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex min-w-0 flex-col gap-1">
<div class="flex min-w-0 items-center gap-2">
<FileText size={16} class="shrink-0 text-secondary" />
<h1 class="truncate text-lg font-semibold text-emphasis" title={artifact.name}>
{artifact.name}
</h1>
</div>
<span class="text-xs font-normal text-secondary">
Shared by {artifact.created_by} · v{artifact.version} · {displayDate(
artifact.shared_at
)} · expires {displayDate(artifact.expires_at)}
</span>
</div>
<div class="flex shrink-0 items-center gap-2">
{#if artifact.can_unshare}
<Button
unifiedSize="sm"
variant="default"
destructive
loading={removing}
onClick={stopSharing}
>
Stop sharing
</Button>
{/if}
<ArtifactExportButton
name={artifact.name}
kind={artifact.kind}
content={artifact.content}
/>
{#if artifact.kind === 'md'}
<ToggleButtonGroup
noWFull
selected={showSource ? 'source' : 'preview'}
onSelected={(v) => (showSource = v === 'source')}
>
{#snippet children({ item })}
<ToggleButton
{item}
value="preview"
icon={Eye}
iconOnly
tooltip="Preview"
size="sm"
/>
<ToggleButton
{item}
value="source"
icon={Code}
iconOnly
tooltip="View source"
size="sm"
/>
{/snippet}
</ToggleButtonGroup>
{/if}
</div>
</div>
<div class="min-h-0 flex-1 overflow-auto rounded-md bg-surface-tertiary px-8">
<ArtifactBody content={artifact.content} {source} />
</div>
{:else if shared.current?.state === 'gone'}
<EmptyState
icon={Link2Off}
title="This shared artifact is no longer available"
description="Its link expired, or its author stopped sharing it."
/>
{:else if shared.current?.state === 'error'}
<EmptyState
icon={Link2Off}
title="Could not load this shared artifact"
description={shared.current.message}
/>
{:else}
<Skeleton layout={[[3], 1, [30]]} />
{/if}
</div>
</div>
@@ -0,0 +1,5 @@
export function load() {
return {
stuff: { title: 'Shared artifact' }
}
}