-
- {user.name}
+
+ {/* ── default info view (left) ──────────────────────────── */}
+
+
+
{t('side.members')}
+ {userList.map((user) => (
+
+ ))}
+
{t('side.referenced_files')}
+ {sess?.references.length
+ ?
{sess.references.join(', ')}
+ :
{t('side.no_pinned_files')}
}
+
{t('side.access')}
+ {sess &&
}
+ {sess &&
{t(`common:share.${sess.shareMode}.desc`)}
}
+
{t('side.session')}
+
{sessionPrefix}
+
+ {t('side.project_label', { name: project.data?.name ?? '...' })}
+
+
setCopyToShared({ scope, paths })}
+ />
+
- ))}
-
{t('side.referenced_files')}
- {sess?.references.length
- ?
{sess.references.join(', ')}
- :
{t('side.no_pinned_files')}
}
-
{t('side.access')}
- {sess &&
}
- {sess &&
{t(`common:share.${sess.shareMode}.desc`)}
}
-
{t('side.session')}
-
{sessionPrefix}
-
- {t('side.project_label', { name: project.data?.name ?? '...' })}
-
-
setCopyToShared({ scope, paths })}
- />
+
+ {/* ── files browser view (right) ────────────────────────── */}
+
+
+
+
+
+ {/* Segmented view switch — pinned outside the sliding track so it stays
+ put in both views. Two tabs: session info ↔ shared file browser. */}
+
+
+ setSideView('info')}
+ >
+
+ {t('side.info_tab')}
+
+ setSideView('files')}
+ >
+
+ {t('side.files_tab')}
+
+
+
{copyToShared !== null && (
diff --git a/app/src/styles/cowork-design-system.css b/app/src/styles/cowork-design-system.css
index 8b62a676..612d3570 100644
--- a/app/src/styles/cowork-design-system.css
+++ b/app/src/styles/cowork-design-system.css
@@ -39,8 +39,8 @@
Subtle wash on top of paper — the "selected" signal lives in the
border / left-stripe / ring rather than in a saturated fill, matching
Drive / OneDrive behavior. Hue stays in the accent family. */
- --cw-selected-bg: oklch(0.955 0.018 215); /* +3% lightness gap, low chroma */
- --cw-selected-bg-2: oklch(0.945 0.022 215); /* selected + hover, barely deeper */
+ --cw-selected-bg: color-mix(in oklch, var(--cw-accent) 14%, transparent); /* accent wash — shared by Files rows/cards + the shared-file panel */
+ --cw-selected-bg-2: color-mix(in oklch, var(--cw-accent) 22%, transparent); /* selected + hover, deeper */
--cw-selected-border: var(--cw-accent);
--cw-selected-ring: color-mix(in oklch, var(--cw-accent), transparent 86%);
diff --git a/app/src/styles/globals.css b/app/src/styles/globals.css
index 757d1e41..26b32003 100644
--- a/app/src/styles/globals.css
+++ b/app/src/styles/globals.css
@@ -246,7 +246,7 @@ body.is-resizing-sidebar .cw-app-shell[data-sidebar-mode="hidden"] .cw-sidebar-a
.cw-brand-lockup strong { color: var(--cw-fg-1); font-size: 22px; line-height: 1; letter-spacing: -0.035em; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cw-section-label-app { padding: 5px 3px 5px; font-size: 10.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--cw-fg-3); font-weight: 500; }
.cw-nav-list, .cw-session-rail { display: flex; flex-direction: column; gap: 1px; }
-.cw-nav-row, .cw-session-row { width: 100%; min-height: 31px; display: flex; align-items: center; gap: 9px; border: 0; border-radius: var(--cw-radius-md); background: transparent; color: var(--cw-fg-2); padding: 6px 8px; text-align: left; font-size: 13px; transition: background 120ms, color 120ms, transform 120ms; }
+.cw-nav-row, .cw-session-row { width: 100%; min-height: 31px; display: flex; align-items: center; gap: 9px; border: 0; border-radius: var(--cw-radius-md); background: transparent; color: var(--cw-fg-2); padding: 6px 8px; text-align: left; font-size: 13px; user-select: none; transition: background 120ms, color 120ms, transform 120ms; }
.cw-nav-row:hover, .cw-session-row:hover { background: var(--cw-bg-muted); color: var(--cw-fg-1); }
.cw-nav-row:active, .cw-session-row:active, .cw-btn-primary:active, .cw-btn-secondary:active { transform: translateY(1px); }
.cw-nav-row.is-active, .cw-session-row.is-active { background: var(--cw-bg-muted); color: var(--cw-fg-1); }
@@ -260,6 +260,8 @@ body.is-resizing-sidebar .cw-app-shell[data-sidebar-mode="hidden"] .cw-sidebar-a
.cw-session-row { min-height: 28px; padding: 5px 8px; font-size: 12.5px; color: var(--cw-fg-3); }
.cw-session-row.is-active { background: color-mix(in oklch, var(--cw-accent) 12%, var(--cw-bg-subtle)); color: var(--cw-fg-1); font-weight: 600; }
.cw-session-row.is-unread { color: var(--cw-fg-1); font-weight: 600; }
+/* Dragging a file from the Files page over a session row — drop to attach it. */
+.cw-session-row.is-drop-target { background: color-mix(in oklch, var(--cw-accent) 18%, var(--cw-bg-subtle)); box-shadow: inset 0 0 0 1.5px var(--cw-accent); color: var(--cw-fg-1); }
.auto-dot { margin-left: auto; color: var(--cw-accent); font-size: 10px; }
.cw-sidebar-user { border-top: 1px solid var(--cw-border); padding: 10px 6px 2px; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 9px; align-items: center; color: var(--cw-fg-2); }
.cw-sidebar-user-meta { min-width: 0; }
@@ -316,7 +318,17 @@ body.is-resizing-sidebar .cw-app-shell[data-sidebar-mode="hidden"] .cw-sidebar-a
.cw-activity-row time { color: var(--cw-fg-3); font-family: var(--cw-font-mono); font-size: 11px; }
.cw-session-layout { display: grid; grid-template-columns: minmax(0, 1fr) 300px; flex: 1; min-height: 0; overflow: hidden; }
-.cw-chat-surface { min-width: 0; display: flex; flex-direction: column; border-right: 1px solid var(--cw-border); background: var(--cw-bg); overflow: hidden; }
+.cw-chat-surface { position: relative; min-width: 0; display: flex; flex-direction: column; border-right: 1px solid var(--cw-border); background: var(--cw-bg); overflow: hidden; }
+/* While a shared file is dragged over the conversation, lay a single uniform
+ accent overlay over the whole surface (a clean drop zone) instead of letting
+ the drag paint a ragged text selection. pointer-events:none so the drop still
+ reaches the surface underneath. */
+.cw-chat-surface.is-import-target::after {
+ content: ''; position: absolute; inset: 0; z-index: 5; pointer-events: none;
+ background: color-mix(in oklch, var(--cw-accent), transparent 82%);
+ /* Ring on the overlay (above content) so opaque children can't break it. */
+ box-shadow: inset 0 0 0 2px var(--cw-accent);
+}
.cw-chat-head { padding: 34px 28px 18px; border-bottom: 1px solid var(--cw-border); display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
.cw-chat-head p { display: flex; align-items: center; gap: 7px; color: var(--cw-fg-3); margin: 14px 0 0; }
/* Session title shares its row with the agent chip (mode lives at title level). */
@@ -337,9 +349,70 @@ body.is-resizing-sidebar .cw-app-shell[data-sidebar-mode="hidden"] .cw-sidebar-a
.cw-message-meta time { color: var(--cw-fg-4); font-family: var(--cw-font-mono); }
.cw-message p { margin: 7px 0 0; line-height: 1.65; white-space: pre-wrap; }
.cw-composer small { grid-column: 1 / -1; color: var(--cw-fg-4); font-size: 11px; }
-.cw-session-side { padding: 22px 18px; background: var(--cw-bg-subtle); overflow: auto; }
+.cw-session-side { padding: 0; background: var(--cw-bg-subtle); overflow: hidden; display: flex; flex-direction: column; }
.cw-session-side h3 { margin: 16px 0 10px; font-size: 11px; text-transform: uppercase; letter-spacing: .09em; color: var(--cw-fg-3); font-weight: 500; }
.cw-session-side p { color: var(--cw-fg-3); line-height: 1.55; }
+
+/* Two sidebar views (info | file browser) on a horizontal track, ordered to
+ match the bottom tabs: info on the left, files on the right. Switching to
+ Files slides the track left so the file browser glides in from the right. */
+.cw-side-views { flex: 1; min-height: 0; display: flex; width: 200%; transform: translateX(0); transition: transform .28s cubic-bezier(.4, 0, .2, 1); }
+.cw-side-views.show-files { transform: translateX(-50%); }
+.cw-side-view { width: 50%; flex: 0 0 50%; min-height: 0; display: flex; flex-direction: column; }
+.cw-side-scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 22px 18px; }
+
+/* Bottom view switch — pinned outside the sliding track, same position in both
+ views. Segmented control: two tabs (session info ↔ shared file browser). */
+.cw-side-switchbar { flex-shrink: 0; padding: 10px 16px; border-top: 1px solid var(--cw-border); }
+.cw-side-seg { display: flex; gap: 2px; width: 100%; padding: 3px; background: var(--cw-bg-muted); border-radius: var(--cw-radius-md); }
+.cw-side-seg-tab { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 30px; border: 0; border-radius: calc(var(--cw-radius-md) - 3px); background: transparent; color: var(--cw-ink-3); font-size: 12.5px; font-weight: 500; cursor: pointer; transition: background 120ms, color 120ms, box-shadow 120ms; }
+.cw-side-seg-tab:hover { color: var(--cw-ink); }
+.cw-side-seg-tab[aria-selected="true"] { background: var(--cw-accent-soft); color: var(--cw-accent-2); box-shadow: inset 0 0 0 1px var(--cw-accent-line); font-weight: 600; }
+
+/* File-browser view inside the sidebar — mirrors the Files page layout in the
+ narrow column: fixed head + breadcrumb, scrolling row list. */
+.cw-files-browser { flex: 1; min-height: 0; display: flex; flex-direction: column; }
+.cw-files-breadcrumb { display: flex; align-items: center; flex-wrap: wrap; gap: 1px; flex-shrink: 0; padding: 20px 14px 8px; font-size: 11px; color: var(--cw-ink-3); }
+.cw-files-up { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; margin-right: 3px; border: 0; border-radius: var(--cw-radius-sm); background: transparent; color: var(--cw-ink-2); cursor: pointer; transition: background 100ms, color 100ms; }
+.cw-files-up:not(:disabled):hover { background: var(--cw-bg-muted); color: var(--cw-ink); }
+/* Reserve the slot at root so entering a folder doesn't shift the breadcrumb. */
+.cw-files-up:disabled { visibility: hidden; }
+/* Keep the project-name root constant whether or not it's the current dir
+ (the home button is disabled at root, enabled deeper — don't let that recolor it). */
+.cw-files-home, .cw-files-home:disabled { gap: 5px; max-width: 150px; color: var(--cw-ink-2); }
+.cw-files-home-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
+.cw-files-breadcrumb button { display: inline-flex; align-items: center; border: 0; background: transparent; color: var(--cw-ink-3); font-size: 11px; cursor: pointer; padding: 2px 3px; border-radius: 4px; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.cw-files-breadcrumb button:not(:disabled):hover { background: var(--cw-bg-muted); color: var(--cw-ink); }
+/* pointer-events:none so releasing a marquee over a disabled (gray) crumb still
+ fires window mouseup — disabled buttons otherwise swallow the event. */
+.cw-files-breadcrumb button:disabled { cursor: default; color: var(--cw-ink-2); pointer-events: none; }
+.cw-files-crumb { display: inline-flex; align-items: center; }
+.cw-files-browser-list { flex: 1; min-height: 0; overflow-y: auto; padding: 0 8px 14px; display: flex; flex-direction: column; gap: 1px; user-select: none; }
+
+/* Shared-file rows: color-chip icon (FileTypeIcon) + name, with a drag handle
+ that fades in on hover to signal the row is draggable. */
+.cw-sf-row { display: flex; align-items: center; gap: 8px; width: 100%; min-height: 34px; padding: 3px 6px 3px 3px; border: 0; border-radius: var(--cw-radius-sm); background: transparent; font-size: 13px; color: var(--cw-fg-1); text-align: left; transition: background 100ms; }
+.cw-sf-row:hover { background: var(--cw-bg-muted); }
+.cw-sf-row.is-selected { background: var(--cw-selected-bg); }
+.cw-sf-row.is-selected:hover { background: var(--cw-selected-bg-2); }
+.cw-sf-row .cw-file-label { flex: 1; }
+.cw-sf-row[draggable="true"] { cursor: grab; }
+/* Left type-icon swaps to a quick-add (+) on hover. */
+.cw-sf-icon { position: relative; width: 18px; height: 18px; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; }
+.cw-sf-icon > :not(.cw-sf-icon-add) { transition: opacity 100ms; }
+.cw-sf-row:hover .cw-sf-icon > :not(.cw-sf-icon-add) { opacity: 0; }
+/* pointer-events:none while hidden — an opacity:0 button still takes clicks, so
+ without this a tap on the icon (touch, where there's no hover to reveal the +)
+ would import the file instead of selecting the row. */
+.cw-sf-icon-add { position: absolute; inset: 0; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 1px solid transparent; border-radius: var(--cw-radius-sm); background: transparent; color: var(--cw-accent); cursor: pointer; opacity: 0; pointer-events: none; transition: opacity 100ms, background 100ms, border-color 100ms; }
+/* Visible (and clickable) on row hover (or focus): a clear neutral border so the
+ + reads as a button. (--cw-border was too faint and washed out on the
+ accent-wash selected fill.) */
+.cw-sf-row:hover .cw-sf-icon-add, .cw-sf-icon-add:focus-visible { opacity: 1; pointer-events: auto; border-color: var(--cw-ink-4); }
+/* Hovering the + itself deepens its fill and its border (neutral, a couple steps darker). */
+.cw-sf-row:hover .cw-sf-icon-add:hover, .cw-sf-icon-add:focus-visible:hover { background: color-mix(in oklch, var(--cw-ink) 12%, transparent); border-color: var(--cw-ink-2); }
+/* Drag affordance hint under the breadcrumb (replaces the old per-row grip). */
+.cw-sf-drag-hint { margin: 0; padding: 0 14px 6px; font-size: 10px; color: var(--cw-ink-4); flex-shrink: 0; user-select: none; }
.cw-side-row, .cw-side-file { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 32px; font-size: 13px; }
.cw-side-file > span { display: inline-flex; align-items: center; gap: 8px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cw-side-file small { color: var(--cw-fg-4); font-family: var(--cw-font-mono); }
@@ -358,6 +431,10 @@ body.is-resizing-sidebar .cw-app-shell[data-sidebar-mode="hidden"] .cw-sidebar-a
.cw-artifact-row { display: flex; align-items: center; gap: 6px; min-height: 30px; padding: 2px 4px; border-radius: var(--cw-radius-sm); font-size: 12.5px; transition: background 100ms; }
.cw-artifact-row:hover { background: var(--cw-bg-muted); }
.cw-artifact-name { flex: 1; min-width: 0; display: inline-flex; align-items: center; gap: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--cw-fg-1); }
+/* The icon stays fixed; the label takes the remaining width and ellipsizes.
+ (text-overflow needs a real element — a bare text node in the flex row won't clip.) */
+.cw-artifact-name > svg, .cw-artifact-name > img { flex-shrink: 0; }
+.cw-file-label { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cw-artifact-size { flex-shrink: 0; font-family: var(--cw-font-mono); font-size: 10.5px; color: var(--cw-fg-4); }
.cw-artifact-menu-wrap { flex-shrink: 0; position: relative; }
.cw-artifact-menu-wrap > button { width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: var(--cw-radius-sm); background: transparent; color: var(--cw-fg-3); cursor: pointer; opacity: 0; transition: opacity 100ms, background 100ms; }
@@ -535,7 +612,7 @@ body.is-resizing-sidebar .cw-app-shell[data-sidebar-mode="hidden"] .cw-sidebar-a
.cw-file-head, .cw-file-row { display: grid; grid-template-columns: minmax(0, 1fr) 110px 130px 24px; gap: 12px; align-items: center; }
.cw-file-head { padding: 10px 8px; border-bottom: 1px solid var(--cw-border); color: var(--cw-fg-3); font-family: var(--cw-font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
.cw-file-row { width: 100%; border: 0; border-bottom: 1px solid var(--cw-line-soft); background: transparent; min-height: 46px; padding: 7px 8px; color: var(--cw-fg-2); text-align: left; }
-.cw-file-row:hover, .cw-file-row.is-selected { background: var(--cw-bg); }
+.cw-file-row:hover { background: var(--cw-bg); }
.cw-file-row > span:first-child:not(.cw-pocket) { display: inline-flex; align-items: center; gap: 9px; color: var(--cw-fg-1); }
.cw-knowledge { margin-top: 28px; border: 1px dashed var(--cw-border-hover); border-radius: var(--cw-radius-lg); padding: 18px; background: var(--cw-bg); }
.cw-knowledge h2 { margin: 0; display: flex; align-items: center; gap: 8px; }
@@ -1034,8 +1111,9 @@ button.cw-select[aria-expanded="true"] .cw-select-caret {
border-bottom: 1px solid var(--cw-line-soft);
color: var(--cw-ink);
}
-.cw-file-row:hover,
-.cw-file-row.is-selected { background: var(--cw-paper-2); }
+.cw-file-row:hover { background: var(--cw-paper-2); }
+.cw-file-row.is-selected { background: var(--cw-selected-bg); }
+.cw-file-row.is-selected:hover { background: var(--cw-selected-bg-2); }
.cw-file-main {
flex: 1;
min-width: 0;
@@ -1613,6 +1691,11 @@ button.cw-select[aria-expanded="true"] .cw-select-caret {
transition: background 120ms, border-color 120ms;
}
.cw-attach-chip--file:hover { background: var(--cw-paper-4); border-color: var(--cw-border-hover); }
+/* Source marker on an attachment chip: folder = referenced from shared files
+ (accent), clip = uploaded via the composer (muted). */
+.cw-attach-source { display: inline-flex; align-items: center; flex-shrink: 0; }
+.cw-attach-source--shared { color: var(--cw-accent); }
+.cw-attach-source--upload { color: var(--cw-ink-2); }
.cw-attach-name {
flex: 1;
min-width: 0;
@@ -1620,18 +1703,13 @@ button.cw-select[aria-expanded="true"] .cw-select-caret {
text-overflow: ellipsis;
white-space: nowrap;
}
-.cw-attach-remove {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- border: 0;
- background: transparent;
- color: var(--cw-ink-4);
- cursor: pointer;
- padding: 0;
- flex-shrink: 0;
-}
-.cw-attach-remove:hover { color: var(--cw-ink); }
+/* Pending composer chip: the whole chip is click-to-remove; hovering turns it red. */
+.cw-attach-chip--removable { cursor: pointer; transition: background 120ms, border-color 120ms, color 120ms; }
+.cw-attach-chip--removable:hover { background: color-mix(in oklch, var(--cw-destructive), white 90%); border-color: color-mix(in oklch, var(--cw-destructive), transparent 55%); color: var(--cw-destructive); }
+.cw-attach-chip--removable:focus-visible { outline: 2px solid color-mix(in oklch, var(--cw-destructive), transparent 40%); outline-offset: 1px; }
+/* Persistent × affordance; muted by default, reddens with the chip on hover. */
+.cw-attach-remove-hint { display: inline-flex; align-items: center; flex-shrink: 0; color: var(--cw-ink-4); }
+.cw-attach-chip--removable:hover .cw-attach-remove-hint { color: var(--cw-destructive); }
.cw-attach-error { color: var(--cw-destructive); display: inline-flex; }
.cw-attach-spinner { font-size: 10px; }
diff --git a/backend/src/handlers/session.rs b/backend/src/handlers/session.rs
index 2202369a..6edd2c27 100644
--- a/backend/src/handlers/session.rs
+++ b/backend/src/handlers/session.rs
@@ -356,6 +356,21 @@ async fn resolve_session_id(state: &Arc
, session_ref: &str) -> ApiResu
}
}
+/// Hard ceiling on attachments per message — a server-side backstop for the
+/// frontend's softer limit. Bounds the agent prompt, DB row, and fs stats even
+/// if a crafted request bypasses the client.
+const MAX_ATTACHMENTS: usize = 30;
+
+/// Reject a message that attaches more than [`MAX_ATTACHMENTS`] files.
+pub fn ensure_attachment_count(count: usize) -> ApiResult<()> {
+ if count > MAX_ATTACHMENTS {
+ return Err(AppError::bad_request(format!(
+ "too many attachments: {count} (max {MAX_ATTACHMENTS})"
+ )));
+ }
+ Ok(())
+}
+
async fn validate_attachments(
state: &Arc,
auth_user: &AuthUser,
@@ -363,6 +378,7 @@ async fn validate_attachments(
project_id: Uuid,
attachments: &[String],
) -> ApiResult<()> {
+ ensure_attachment_count(attachments.len())?;
for path in attachments {
let parsed = parse_dirent_path(path)
.map_err(|_| AppError::bad_request(format!("invalid attachment path: {path}")))?;
diff --git a/backend/tests/session_messages_test.rs b/backend/tests/session_messages_test.rs
index 86e775e0..a94982b8 100644
--- a/backend/tests/session_messages_test.rs
+++ b/backend/tests/session_messages_test.rs
@@ -7,7 +7,7 @@ use std::sync::Arc;
use agent_k::agents::{GUEST_ATTACHED_DIR, GUEST_SHARED_DIR};
use agent_k_backend::{
- handlers::{build_attachment_note, inject_attachment_note},
+ handlers::{build_attachment_note, ensure_attachment_count, inject_attachment_note},
repository,
state::AppState,
};
@@ -811,6 +811,7 @@ fn inject_then_re_inject_is_idempotent_on_text_prefix() {
);
}
+
// ── reverse-indexed pagination ────────────────────────────────────────────────
fn history_texts(body: &serde_json::Value) -> Vec {
@@ -845,6 +846,17 @@ fn history_seqs(body: &serde_json::Value) -> std::collections::HashMap